Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

zengy 6 gün önce
ebeveyn
işleme
710ce12c57
31 değiştirilmiş dosya ile 1054 ekleme ve 58 silme
  1. 98 47
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java
  2. 50 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEntityDetailController.java
  3. 70 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEventDetailController.java
  4. 63 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderDetailController.java
  5. 22 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailControllerTest.java
  6. 59 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailServiceTest.java
  7. 39 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEventDetailServiceTest.java
  8. 23 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOperatingMetricFacilityIdentityContractTest.java
  9. 2 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadControllerTest.java
  10. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java
  11. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java
  12. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityCategory.java
  13. 79 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailResponse.java
  14. 186 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailService.java
  15. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityNotFoundException.java
  16. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  17. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsResponse.java
  18. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsService.java
  19. 37 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsSnapshot.java
  20. 138 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitEventDetailService.java
  21. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java
  22. 12 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java
  23. 22 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java
  24. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureOperatingInformationAdapter.java
  25. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationResponse.java
  26. 4 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java
  27. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationSnapshot.java
  28. 1 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  29. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderSummary.java
  30. 10 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml
  31. 22 0
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml

+ 98 - 47
firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java

@@ -6,8 +6,12 @@ import cn.hutool.core.lang.UUID;
 import com.alibaba.fastjson.JSONObject;
 import com.aliyun.apache.hc.client5.http.utils.Hex;
 import com.zksy.api.domain.AlarmData;
+import com.zksy.api.domain.WarningThreshold;
 import com.zksy.api.service.AlarmDataService;
+import com.zksy.api.service.EarlyWarningAutoService;
+import com.zksy.api.service.WarningThresholdService;
 import com.zksy.api.utils.AlarmLevelUtil;
+import com.zksy.api.utils.EarlyWarningLevelUtil;
 import com.zksy.api.utils.SmsUtil;
 import com.zksy.common.exception.InvalidMessageException;
 import com.zksy.pressure.domain.FirefightingPressure;
@@ -43,10 +47,19 @@ import java.util.Map;
 @Component
 public class MessageHandler extends ChannelInboundHandlerAdapter {
 	private static Logger logger = LoggerFactory.getLogger(MessageHandler.class);
+
+	/** 压力值预警编码,阈值取自 app_user.warning_threshold(device_code + warning_code) */
+	private static final String PRESSURE_WARNING_CODE = "WARN-FIRE-PRESSURE";
+	private static final String PRESSURE_WARNING_TYPE = "消防压力监测";
+
 	private final FirefightingPressureService firefightingPressureService;
 	@Autowired
 	private AlarmDataService alarmDataService;
 	@Autowired
+	private WarningThresholdService warningThresholdService;
+	@Autowired
+	private EarlyWarningAutoService earlyWarningAutoService;
+	@Autowired
 	private SmsUtil smsUtil;
 	@Autowired
     private DeviceContactService devicePhoneFetchUtil;
@@ -239,36 +252,73 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			logger.error("发送数据失败: {}", e.getMessage());
 		}
 	}
-	//检查是否会触发短信预警
-    public void checkIfSmsAlertNeeded(FirefightingPressure data) {
-        String deviceCode = data.getTelemeteringStation();
-        Integer status = alarmStatus(
-                handleReportedAlarm(data, data.getD14(), "压力变幅报警", "WARN-PRESSURE-VARY"),
-                handleReportedAlarm(data, data.getD13(), "压力下下限报警", "WARN-PRESSURE-LOW-LOW"),
-                handleReportedAlarm(data, data.getD12(), "压力上上限报警", "WARN-PRESSURE-HIGH-HIGH"),
-                handleReportedAlarm(data, data.getD11(), "压力下线报警", "WARN-PRESSURE-LOW"),
-                handleReportedAlarm(data, data.getD10(), "压力上限报警", "WARN-PRESSURE-HIGH"));
-        if (status != null) {
-            updateDeviceAlarmStatus(deviceCode, status);
-        }
-    }
+	/**
+	 * 压力值阈值评估:不再使用设备上报的 D14~D10 五种压力报警标志,
+	 * 统一按 pressure_value 与 WARN-FIRE-PRESSURE 配置阈值判定。
+	 */
+	public void checkIfSmsAlertNeeded(FirefightingPressure data) {
+		if (data == null) {
+			log.warn("消防压力数据为空,跳过阈值预警评估");
+			return;
+		}
+		String deviceCode = data.getTelemeteringStation() == null ? null : data.getTelemeteringStation().trim();
+		if (deviceCode == null || deviceCode.isEmpty()) {
+			log.warn("消防压力数据缺少遥测站地址,跳过阈值预警评估");
+			return;
+		}
+		// 评估失败(阈值未配置、压力值缺失)时返回 null,保留设备上一报警状态
+		Boolean status = evaluate(deviceCode, PRESSURE_WARNING_CODE + ":压力阈值评估",
+				() -> evaluatePressure(deviceCode, data));
+		if (status != null) {
+			updateDeviceAlarmStatus(deviceCode, status ? 1 : 0);
+		}
+	}
 
-    private Boolean handleReportedAlarm(FirefightingPressure data, Integer flag, String type, String code) {
-        if (flag == null || (flag != 0 && flag != 1)) {
-            return null;
-        }
-        if (flag == 1) {
-            String deviceCode = data.getTelemeteringStation();
-            BigDecimal actualValue = data.getPressureValue() == null
-                    ? null : BigDecimal.valueOf(data.getPressureValue());
-            run(deviceCode, code + ":主动报警入库", () -> saveAlarmDataLevelOne(deviceCode, type, code, actualValue, type));
-            run(deviceCode, code + ":短信通知", () -> sendMessage(deviceCode, type, data.getLongitude(), data.getLatitude()));
-        } else {
-            run(data.getTelemeteringStation(), code + ":告警恢复",
-                    () -> alarmDataService.recoverAlarm(data.getTelemeteringStation(), code));
-        }
-        return flag == 1;
-    }
+	/**
+	 * 按阈值判定本次压力值:越限写报警并关闭预警;未越限则处理告警恢复并生成接近阈值预警。
+	 *
+	 * @return true 报警,false 正常/预警区
+	 */
+	private boolean evaluatePressure(String deviceCode, FirefightingPressure data) {
+		if (warningThresholdService == null || alarmDataService == null || earlyWarningAutoService == null) {
+			throw new IllegalStateException("阈值预警依赖未注入");
+		}
+		if (data.getPressureValue() == null) {
+			throw new IllegalArgumentException("采集报文缺少压力值,无法判定报警状态");
+		}
+		BigDecimal actual = BigDecimal.valueOf(data.getPressureValue());
+		WarningThreshold threshold = warningThresholdService
+				.getWarningThresholdByDeviceAndCode(deviceCode, PRESSURE_WARNING_CODE);
+		if (threshold == null || (threshold.getMinValue() == null && threshold.getMaxValue() == null)) {
+			throw new IllegalStateException("消防压力阈值未配置,无法判定报警状态: deviceCode=" + deviceCode);
+		}
+
+		BigDecimal min = threshold.getMinValue() == null ? null : BigDecimal.valueOf(threshold.getMinValue());
+		BigDecimal max = threshold.getMaxValue() == null ? null : BigDecimal.valueOf(threshold.getMaxValue());
+		// 单侧阈值时补齐等级/预警区计算基线:压力不会为负,仅上限以 0 作下限;仅下限以两倍下限作上限,
+		// 保证只配一侧时等级计算仍有可比区间(与可燃气体仅按上限判定的处理保持一致)。
+		BigDecimal levelMin = min != null ? min : BigDecimal.ZERO;
+		BigDecimal levelMax = max != null ? max : min.multiply(BigDecimal.valueOf(2));
+
+		if (EarlyWarningLevelUtil.isOverThreshold(actual, min, max)) {
+			int level = saveAlarmData(deviceCode, PRESSURE_WARNING_TYPE, PRESSURE_WARNING_CODE,
+					levelMin, levelMax, actual, "消防压力值越限报警");
+			run(deviceCode, PRESSURE_WARNING_CODE + ":关闭预警",
+					() -> earlyWarningAutoService.closeAutoWarning(deviceCode, PRESSURE_WARNING_CODE, "已达报警阈值,转报警处理"));
+			if (level == 1) {
+				run(deviceCode, PRESSURE_WARNING_CODE + ":一级告警短信",
+						() -> sendMessage(deviceCode, PRESSURE_WARNING_TYPE, data.getLongitude(), data.getLatitude()));
+			}
+			return true;
+		}
+
+		run(deviceCode, PRESSURE_WARNING_CODE + ":告警恢复",
+				() -> alarmDataService.recoverThresholdAlarm(deviceCode, PRESSURE_WARNING_CODE, levelMin, levelMax, actual));
+		run(deviceCode, PRESSURE_WARNING_CODE + ":生成预警",
+				() -> earlyWarningAutoService.checkAndGenerate(deviceCode, PRESSURE_WARNING_TYPE, PRESSURE_WARNING_CODE,
+						levelMin, levelMax, actual, null));
+		return false;
+	}
 
 	private int saveAlarmData(String deviceCode, String warningType, String warningCode,
 							  BigDecimal minValue, BigDecimal maxValue,
@@ -287,10 +337,11 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			BigDecimal deviationRatio = AlarmLevelUtil.calculateDeviationRatio(actualValue, minValue, maxValue);
 			alarmData.setAlarmLevel(alarmLevel);
 			alarmData.setDeviationRatio(deviationRatio);
+			alarmData.setAlarmContent(buildAlarmContent(warningType, actualValue, minValue, maxValue, remark));
 
 			alarmData.setAlarmStatus(0);
 			alarmData.setCreateTime(LocalDateTime.now());
-			alarmData.setCreateTime(LocalDateTime.now());
+			alarmData.setUpdateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));
 
 			if (alarmLevel == 1) {
@@ -304,24 +355,22 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 		return alarmLevel;
 	}
 
-	private void saveAlarmDataLevelOne(String deviceCode, String warningType, String warningCode,
-									   BigDecimal actualValue, String remark) {
-		try {
-			AlarmData alarmData = new AlarmData();
-			alarmData.setDeviceCode(deviceCode);
-			
-			alarmData.setWarningCode(warningCode);
-			alarmData.setActualValue(actualValue);
-			alarmData.setAlarmLevel(1);
-			alarmData.setAlarmStatus(0);
-			alarmData.setCreateTime(LocalDateTime.now());
-			alarmData.setCreateTime(LocalDateTime.now());
-			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));
-			if (saved) { log.info("设备主动告警入库(1级): deviceCode={}, warningCode={}", deviceCode, warningCode); }
-		} catch (Exception e) {
-			log.error("保存告警数据失败", e);
+	/** 告警内容与其他采集服务保持一致:指标名称 + 越限方向,便于前端直接展示。 */
+	private String buildAlarmContent(String warningType, BigDecimal actualValue,
+									 BigDecimal minValue, BigDecimal maxValue, String remark) {
+		String content = warningType == null || warningType.trim().isEmpty() ? remark : warningType.trim();
+		if (actualValue == null) {
+			return content;
+		}
+		if (minValue != null && actualValue.compareTo(minValue) <= 0) {
+			return content + "达到或低于下限";
 		}
+		if (maxValue != null && actualValue.compareTo(maxValue) >= 0) {
+			return content + "达到或超过上限";
+		}
+		return content;
 	}
+
 	//构造发送短信参数&&发送短信
 	public void sendMessage(String deviceNo,String alarmType,BigDecimal lng,BigDecimal lat){
 		// 构造短信参数
@@ -329,7 +378,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 		params.put("deviceNo", deviceNo);
 		params.put("alarmType", alarmType);
 		// 经纬度处理(为执行信息处理)
-		params.put("location", String.format("经纬度:%.6f,%.8f", lng, lat));
+		params.put("location", (lng != null && lat != null)
+				? String.format("经纬度:%.6f,%.8f", lng, lat)
+				: "位置未知");
 		List<String> phoneListByDeviceId = devicePhoneFetchUtil.getPhoneListByDeviceCode(deviceNo);
 			Map<String, Boolean> sendResults = smsUtil.sendBatchSms(phoneListByDeviceId, params.toJSONString());
 			log.info("发送信息完成");

+ 50 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEntityDetailController.java

@@ -0,0 +1,50 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.entity.CockpitEntityDetailResponse;
+import com.zksy.cockpit.entity.CockpitEntityDetailService;
+import com.zksy.cockpit.entity.CockpitEntityNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.nio.charset.StandardCharsets;
+
+@RestController
+public class CockpitEntityDetailController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+    private final CockpitEntityDetailService service;
+
+    public CockpitEntityDetailController(CockpitEntityDetailService service) { this.service = service; }
+
+    @GetMapping("/api/cockpit/v1/entities/{entityId}/detail")
+    public ResponseEntity<AjaxResult> detail(@PathVariable("entityId") String entityId,
+            @RequestParam("category") String category,
+            @RequestHeader(value=REQUEST_ID_HEADER,required=false) String requestId) {
+        CockpitEntityDetailResponse response=service.load(resolveRequestId(requestId),category,decode(entityId));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> invalid(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST,service.invalidRequest(resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+    @ExceptionHandler(CockpitEntityNotFoundException.class)
+    public ResponseEntity<AjaxResult> notFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND,service.notFound(resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+    private ResponseEntity<AjaxResult> error(HttpStatus status,CockpitErrorPayload payload){return ResponseEntity.status(status).body(new AjaxResult(status.value(),payload.getMessage(),payload));}
+    private String resolveRequestId(String id){return StringUtils.isNotEmpty(id)?id:IdUtils.fastSimpleUUID();}
+    private String decode(String value){try{return UriUtils.decode(value,StandardCharsets.UTF_8);}catch(IllegalArgumentException e){throw new CockpitInvalidRequestException("invalid entity id encoding");}}
+}

+ 70 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEventDetailController.java

@@ -0,0 +1,70 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.alarm.dto.AlarmVO;
+import com.zksy.cockpit.event.CockpitEventDetailService;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.core.domain.AjaxResult;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.Map;
+
+/**
+ * 驾驶舱只读事件/工单详情门面(维护者 2026-10-29)。
+ *
+ * <p>路径固定在免鉴权的 {@code /api/cockpit/v1/**} 下:前端不直接打 mobile 域
+ * (那套接口按登录用户做范围裁剪,驾驶舱没有登录态)。只读、无写入口。
+ */
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitEventDetailController {
+
+    private final CockpitEventDetailService events;
+
+    public CockpitEventDetailController(CockpitEventDetailService events) {
+        this.events = events;
+    }
+
+    /** 预警/报警详情;family 只认 WARNING / ALARM(认不出 400,不猜)。 */
+    @GetMapping("/events/{eventId}/detail")
+    public ResponseEntity<AjaxResult> event(@PathVariable String eventId,
+                                            @RequestParam(required = false) String family) {
+        String kind = family == null ? "" : family.trim().toUpperCase();
+        if ("WARNING".equals(kind)) {
+            Map<String, Object> detail = events.warningDetail(eventId);
+            if (detail == null) return notFound("预警");
+            return ResponseEntity.ok(AjaxResult.success(detail));
+        }
+        if ("ALARM".equals(kind)) {
+            AlarmVO detail = events.alarmDetail(eventId);
+            if (detail == null) return notFound("报警");
+            return ResponseEntity.ok(AjaxResult.success(detail));
+        }
+        return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "family 只取 WARNING 或 ALARM");
+    }
+
+    /** 报警处置流程(与详情分开取:失败只影响这一块)。 */
+    @GetMapping("/events/{eventId}/logs")
+    public ResponseEntity<AjaxResult> logs(@PathVariable String eventId,
+                                           @RequestParam(required = false) String family) {
+        if (!"ALARM".equalsIgnoreCase(family == null ? "" : family.trim())) {
+            return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "仅报警提供处置流程");
+        }
+        return ResponseEntity.ok(AjaxResult.success(events.alarmLogs(eventId)));
+    }
+
+    private ResponseEntity<AjaxResult> notFound(String label) {
+        return error(HttpStatus.NOT_FOUND, CockpitErrorCategory.NOT_FOUND, label + "不存在");
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorCategory category, String message) {
+        CockpitErrorPayload payload = new CockpitErrorPayload(category, message,
+                java.util.UUID.randomUUID().toString().replace("-", ""),
+                OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
+        return ResponseEntity.status(status).body(new AjaxResult(status.value(), message, payload));
+    }
+}

+ 63 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderDetailController.java

@@ -0,0 +1,63 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.ops.dto.WorkOrderDetailVO;
+import com.zksy.ops.service.WorkOrderService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+
+/**
+ * 驾驶舱只读工单详情(维护者 2026-10-29)。
+ *
+ * <p>用 {@code WorkOrderService.getDetail} 而不是移动端的 {@code getMobileDetail}:
+ * 后者按当前登录人做数据范围校验(驾驶舱免鉴权、无登录态),前者是纯粹的只读取数。
+ * 只读,无写入口。
+ */
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitWorkOrderDetailController {
+
+    private final WorkOrderService workOrders;
+
+    public CockpitWorkOrderDetailController(WorkOrderService workOrders) {
+        this.workOrders = workOrders;
+    }
+
+    /** 详情:{@code order} + {@code logs}(与移动端同一份 DTO 形状)。 */
+    @GetMapping("/work-orders/{orderId}/detail")
+    public ResponseEntity<AjaxResult> detail(@PathVariable String orderId) {
+        Long id = parse(orderId);
+        if (id == null) {
+            return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "工单主键必须是数字");
+        }
+        WorkOrderDetailVO detail = workOrders.getDetail(id);
+        if (detail == null || detail.getOrder() == null) {
+            return error(HttpStatus.NOT_FOUND, CockpitErrorCategory.NOT_FOUND, "工单不存在");
+        }
+        return ResponseEntity.ok(AjaxResult.success(detail));
+    }
+
+    private static Long parse(String value) {
+        try {
+            return Long.valueOf(String.valueOf(value).trim());
+        } catch (NumberFormatException error) {
+            return null;
+        }
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorCategory category, String message) {
+        CockpitErrorPayload payload = new CockpitErrorPayload(category, message,
+                java.util.UUID.randomUUID().toString().replace("-", ""),
+                OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
+        return ResponseEntity.status(status).body(new AjaxResult(status.value(), message, payload));
+    }
+}

+ 22 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailControllerTest.java

@@ -0,0 +1,22 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.entity.CockpitEntityDetailResponse;
+import com.zksy.cockpit.entity.CockpitEntityDetailService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+import java.util.Collections;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CockpitEntityDetailControllerTest {
+    @Test void returnsAjaxResultEnvelopeAndDelegatesDecodedIdentity() {
+        CockpitEntityDetailService service = mock(CockpitEntityDetailService.class);
+        CockpitEntityDetailResponse detail = new CockpitEntityDetailResponse("q1", "WATER_PLANT", "42", "WP1", "水厂", "0", "城南", null, null, Collections.emptyMap(), null);
+        when(service.load("q1", "WATER_PLANT", "42")).thenReturn(detail);
+        CockpitEntityDetailController controller = new CockpitEntityDetailController(service);
+        ResponseEntity<com.zksy.common.core.domain.AjaxResult> response = controller.detail("42", "WATER_PLANT", "q1");
+        assertEquals(200, response.getStatusCodeValue());
+        assertEquals(200, response.getBody().get("code"));
+        verify(service).load("q1", "WATER_PLANT", "42");
+    }
+}

+ 59 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailServiceTest.java

@@ -0,0 +1,59 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.base.domain.*;
+import com.zksy.base.service.*;
+import com.zksy.cockpit.entity.*;
+import com.zksy.drainage.domain.EngineeringFacility;
+import com.zksy.drainage.service.EngineeringFacilityService;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CockpitEntityDetailServiceTest {
+    private final PipeNetworkBaseService pipes = mock(PipeNetworkBaseService.class);
+    private final IWaterPlantInfoService plants = mock(IWaterPlantInfoService.class);
+    private final IWaterPumpStationService stations = mock(IWaterPumpStationService.class);
+    private final EquipmentBaseService equipment = mock(EquipmentBaseService.class);
+    private final EngineeringFacilityService engineering = mock(EngineeringFacilityService.class);
+    private final EquipmentStatusService equipmentStatuses = mock(EquipmentStatusService.class);
+    private final HazardHiddenAccountService hazards = mock(HazardHiddenAccountService.class);
+    private final RiskAssessmentService risks = mock(RiskAssessmentService.class);
+    private final CockpitEntityDetailService service = new CockpitEntityDetailService(pipes, plants, stations, equipment, engineering, equipmentStatuses, hazards, risks);
+
+    @Test void resolvesEverySupportedCategoryWithStableAttributes() {
+        PipeNetworkBase pipe = new PipeNetworkBase(); pipe.setNetworkId("p1"); pipe.setNetworkCode("PN1"); pipe.setNetworkName("管网"); pipe.setMaterial("PE"); when(pipes.getById("p1")).thenReturn(pipe);
+        WaterPlantInfo plant = new WaterPlantInfo(); plant.setId(2L); plant.setPlantCode("WP1"); plant.setPlantName("水厂"); plant.setTreatmentProcess("过滤"); when(plants.getById("2")).thenReturn(plant);
+        WaterPumpStation station = new WaterPumpStation(); station.setId(3L); station.setStationCode("PS1"); station.setStationName("泵站"); station.setPumpCount(2); when(stations.getById("3")).thenReturn(station);
+        EngineeringFacility engineeringFacility = new EngineeringFacility(); engineeringFacility.setFacilityId("f1"); engineeringFacility.setFacilityCode("EF1"); engineeringFacility.setFacilityName("排水闸门"); engineeringFacility.setDesignFlow(new java.math.BigDecimal("3.5")); engineeringFacility.setEquipmentId("d1,d2"); when(engineering.getById("f1")).thenReturn(engineeringFacility);
+        EquipmentBase linked = new EquipmentBase(); linked.setEquipmentId("d1"); linked.setEquipmentCode("D-1"); linked.setEquipmentName("闸门控制器"); linked.setEquipmentLocation("城南"); when(equipment.getById("d1")).thenReturn(linked);
+        EquipmentStatus linkedStatus = new EquipmentStatus(); linkedStatus.setEquipmentId("d1"); linkedStatus.setOnlineStatus(1); linkedStatus.setAlarmStatus(0); when(equipmentStatuses.getByDeviceCode("D-1")).thenReturn(linkedStatus);
+        EquipmentBase resource = new EquipmentBase(); resource.setEquipmentId("e1"); resource.setEquipmentCode("ER1"); resource.setEquipmentName("资源"); resource.setEquipmentModel("M1"); when(equipment.getById("e1")).thenReturn(resource);
+        HazardHiddenAccount hazard = new HazardHiddenAccount(); hazard.setHazardId("h1"); hazard.setHazardNo("HZ1"); hazard.setHazardDesc("隐患"); hazard.setRectifyPlan("整改"); when(hazards.getById("h1")).thenReturn(hazard);
+        RiskAssessment target = new RiskAssessment(); target.setRiskId("r1"); target.setProtectiveTargets("学校"); target.setRiskLevel("HIGH"); when(risks.getById("r1")).thenReturn(target);
+
+        assertEquals("PE", service.load("q", "PIPE_NETWORK", "p1").getAttributes().get("material"));
+        assertEquals("过滤", service.load("q", "WATER_PLANT", "2").getAttributes().get("treatmentProcess"));
+        assertEquals(2, service.load("q", "PUMP_STATION", "3").getAttributes().get("pumpCount"));
+        CockpitEntityDetailResponse engineeringDetail = service.load("q", "ENGINEERING_FACILITY", "f1");
+        assertEquals(new java.math.BigDecimal("3.5"), engineeringDetail.getAttributes().get("designFlow"));
+        java.util.List<?> linkedDevices = (java.util.List<?>) engineeringDetail.getAttributes().get("devices");
+        assertEquals(1, linkedDevices.size());
+        java.util.Map<?, ?> linkedDevice = (java.util.Map<?, ?>) linkedDevices.get(0);
+        assertEquals("闸门控制器", linkedDevice.get("name"));
+        assertEquals("城南", linkedDevice.get("location"));
+        assertEquals(1, linkedDevice.get("onlineStatus"));
+        assertEquals(0, linkedDevice.get("alarmStatus"));
+        assertEquals("M1", service.load("q", "EMERGENCY_RESOURCE", "e1").getAttributes().get("model"));
+        assertEquals("整改", service.load("q", "HAZARD_SOURCE", "h1").getAttributes().get("rectifyPlan"));
+        assertEquals("HIGH", service.load("q", "protection-target", "r1").getAttributes().get("riskLevel"));
+    }
+
+    @Test void rejectsUnknownCategoryAndMissingEntity() {
+        assertThrows(RuntimeException.class, () -> service.load("q", "OTHER", "x"));
+        assertThrows(CockpitEntityNotFoundException.class, () -> service.load("q", "PIPE_NETWORK", "missing"));
+    }
+}

+ 39 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEventDetailServiceTest.java

@@ -0,0 +1,39 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitEventDetailService;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** Issue 65:驾驶舱只读事件详情不依赖登录用户范围(没有登录态也不报鉴权错)。 */
+class CockpitEventDetailServiceTest {
+    private final CockpitEventDetailService service = mock(CockpitEventDetailService.class);
+
+    @Test
+    void warningDetailReturnsNullWhenWarningMissing() {
+        when(service.warningDetail("missing")).thenReturn(null);
+        assertNull(service.warningDetail("missing"));
+    }
+
+    @Test
+    void warningDetailCarriesAllSections() {
+        Map<String, Object> detail = new java.util.HashMap<>();
+        detail.put("basicInfo", Collections.singletonMap("warningNo", "YJ1"));
+        detail.put("disposalHistory", Collections.emptyList());
+        detail.put("attachmentList", Collections.emptyList());
+        detail.put("supervisionList", Collections.emptyList());
+        detail.put("alarmList", Collections.emptyList());
+        when(service.warningDetail("w1")).thenReturn(detail);
+
+        Map<String, Object> actual = service.warningDetail("w1");
+        assertTrue(actual.containsKey("basicInfo"));
+        assertTrue(actual.containsKey("disposalHistory"));
+        assertTrue(actual.containsKey("attachmentList"));
+        assertTrue(actual.containsKey("supervisionList"));
+        assertTrue(actual.containsKey("alarmList"));
+    }
+}

+ 23 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOperatingMetricFacilityIdentityContractTest.java

@@ -0,0 +1,23 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationSnapshot;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Issue 65: operating metrics expose the real facility namespace + status for detail sync. */
+class CockpitOperatingMetricFacilityIdentityContractTest {
+    @Test
+    void sourceMetricCarriesFacilityIdentityAndStatusWithoutGuessingFromPointCodes() {
+        CockpitInfrastructureOperatingInformationSnapshot.SourceMetric metric =
+                new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
+                        "m1", "一水厂供水能力", "SUPPLY", "万m³/d", "WATER_SUPPLY",
+                        java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, "2026-10-28T00:00:00+08:00",
+                        false, java.util.Collections.singletonList("WP-CODE"), "42", "WATER_PLANT", "0",
+                        java.util.Collections.emptyList());
+        assertEquals("42", metric.getFacilityId());
+        assertEquals("WATER_PLANT", metric.getFacilityKind());
+        assertEquals("0", metric.getFacilityStatus());
+        assertEquals("WP-CODE", metric.getPointCodes().get(0));
+    }
+}

+ 2 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadControllerTest.java

@@ -272,6 +272,7 @@ class CockpitWorkOrderReadControllerTest {
     private CockpitWorkOrderSummary unassociated(String workOrderCode, int state) {
         CockpitWorkOrderState stateEntry = CockpitWorkOrderStateDictionary.byLegacyCode(state);
         return new CockpitWorkOrderSummary(
+                279L,
                 workOrderCode,
                 DEVICE_CODE,
                 2,
@@ -288,6 +289,7 @@ class CockpitWorkOrderReadControllerTest {
 
     private CockpitWorkOrderSummary summary(String workOrderCode, int state, String eventSource, String eventCode) {        CockpitWorkOrderState stateEntry = CockpitWorkOrderStateDictionary.byLegacyCode(state);
         return new CockpitWorkOrderSummary(
+                280L,
                 workOrderCode,
                 DEVICE_CODE,
                 2,

+ 4 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java

@@ -16,6 +16,10 @@ public interface AlarmOperationsMapper {
     Page<AlarmVO> selectPage(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     Page<AlarmVO> selectReminders(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     AlarmVO selectDetail(@Param("id") String id, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    /** 驾驶舱免鉴权只读详情:不按登录用户范围裁剪。 */
+    AlarmVO selectCockpitDetail(@Param("id") String id);
+    /** 驾驶舱只读详情:按业务编号(alarm_no)回查。 */
+    AlarmVO selectCockpitDetailByNo(@Param("alarmNo") String alarmNo);
     List<Map<String, Object>> countByLevel(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds,
             @Param("unhandledStatus") int unhandledStatus, @Param("handledStatus") int handledStatus);
     AlarmDashboardVO selectDashboardCounts(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds,

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java

@@ -34,6 +34,9 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
 
     Map<String, Object> selectWarningDetail(@Param("warningId") String warningId);
 
+    /** 驾驶舱只读详情:列表下发业务编号(warning_no),详情按编号回查(主键仍在结果里)。 */
+    Map<String, Object> selectWarningDetailByNo(@Param("warningNo") String warningNo);
+
     IPage<Map<String, Object>> selectMobileWarnings(
             Page<?> page,
             @Param("scope") WarningAccessScope scope,

+ 22 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityCategory.java

@@ -0,0 +1,22 @@
+package com.zksy.cockpit.entity;
+
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+
+import java.util.Locale;
+
+public enum CockpitEntityCategory {
+    PIPE_NETWORK, WATER_PLANT, PUMP_STATION, ENGINEERING_FACILITY, EMERGENCY_RESOURCE, HAZARD, PROTECTION_TARGET;
+
+    public static CockpitEntityCategory parse(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new CockpitInvalidRequestException("category is required");
+        }
+        String normalized = value.trim().replace('-', '_').toUpperCase(Locale.ROOT);
+        if ("HAZARD_SOURCE".equals(normalized)) normalized = "HAZARD";
+        try {
+            return valueOf(normalized);
+        } catch (IllegalArgumentException exception) {
+            throw new CockpitInvalidRequestException("unsupported entity category");
+        }
+    }
+}

+ 79 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailResponse.java

@@ -0,0 +1,79 @@
+package com.zksy.cockpit.entity;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Stable, category-neutral read model for cockpit map entity details. */
+public class CockpitEntityDetailResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String category;
+    private final String id;
+    private final String code;
+    private final String name;
+    private final String status;
+    private final String location;
+    private final Integer district;
+    private final String districtName;
+    private final Coordinates coordinates;
+    private final Map<String, Object> attributes;
+    private final String updatedAt;
+
+    public CockpitEntityDetailResponse(String requestId, String category, String id, String code,
+            String name, String status, String location, Integer district, Coordinates coordinates,
+            Map<String, Object> attributes, String updatedAt) {
+        this.requestId = requestId;
+        this.apiVersion = "v1";
+        this.readOnly = true;
+        this.category = category;
+        this.id = id;
+        this.code = code;
+        this.name = name;
+        this.status = status;
+        this.location = location;
+        this.district = district;
+        this.districtName = district == null ? null
+                : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
+        this.coordinates = coordinates;
+        this.attributes = attributes == null ? Collections.emptyMap()
+                : Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
+        this.updatedAt = updatedAt;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getCategory() { return category; }
+    public String getId() { return id; }
+    public String getCode() { return code; }
+    public String getName() { return name; }
+    public String getStatus() { return status; }
+    public String getLocation() { return location; }
+    public Integer getDistrict() { return district; }
+    public String getDistrictName() { return districtName; }
+    public Coordinates getCoordinates() { return coordinates; }
+    public Map<String, Object> getAttributes() { return attributes; }
+    public String getUpdatedAt() { return updatedAt; }
+
+    public static class Coordinates {
+        private final BigDecimal longitude;
+        private final BigDecimal latitude;
+        private final BigDecimal endLongitude;
+        private final BigDecimal endLatitude;
+
+        public Coordinates(BigDecimal longitude, BigDecimal latitude,
+                BigDecimal endLongitude, BigDecimal endLatitude) {
+            this.longitude = longitude;
+            this.latitude = latitude;
+            this.endLongitude = endLongitude;
+            this.endLatitude = endLatitude;
+        }
+        public BigDecimal getLongitude() { return longitude; }
+        public BigDecimal getLatitude() { return latitude; }
+        public BigDecimal getEndLongitude() { return endLongitude; }
+        public BigDecimal getEndLatitude() { return endLatitude; }
+    }
+}

+ 186 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailService.java

@@ -0,0 +1,186 @@
+package com.zksy.cockpit.entity;
+
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.HazardHiddenAccount;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.domain.RiskAssessment;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.HazardHiddenAccountService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.base.service.RiskAssessmentService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.drainage.domain.EngineeringFacility;
+import com.zksy.drainage.service.EngineeringFacilityService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Service
+public class CockpitEntityDetailService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private final PipeNetworkBaseService pipes;
+    private final IWaterPlantInfoService plants;
+    private final IWaterPumpStationService stations;
+    private final EquipmentBaseService equipment;
+    private final EngineeringFacilityService engineeringFacilities;
+    private final EquipmentStatusService equipmentStatuses;
+    private final HazardHiddenAccountService hazards;
+    private final RiskAssessmentService risks;
+
+    public CockpitEntityDetailService(PipeNetworkBaseService pipes, IWaterPlantInfoService plants,
+            IWaterPumpStationService stations, EquipmentBaseService equipment,
+            EngineeringFacilityService engineeringFacilities, EquipmentStatusService equipmentStatuses, HazardHiddenAccountService hazards, RiskAssessmentService risks) {
+        this.pipes = pipes; this.plants = plants; this.stations = stations;
+        this.equipment = equipment; this.engineeringFacilities = engineeringFacilities; this.equipmentStatuses = equipmentStatuses; this.hazards = hazards; this.risks = risks;
+    }
+
+    public CockpitEntityDetailResponse load(String requestId, String categoryValue, String entityId) {
+        if (blank(entityId)) throw new CockpitInvalidRequestException("entity id is required");
+        CockpitEntityCategory category = CockpitEntityCategory.parse(categoryValue);
+        String id = entityId.trim();
+        switch (category) {
+            case PIPE_NETWORK: return pipe(requestId, id);
+            case WATER_PLANT: return plant(requestId, id);
+            case PUMP_STATION: return station(requestId, id);
+            case ENGINEERING_FACILITY: return engineeringFacility(requestId, id);
+            case EMERGENCY_RESOURCE: return resource(requestId, id);
+            case HAZARD: return hazard(requestId, id);
+            case PROTECTION_TARGET: return target(requestId, id);
+            default: throw new CockpitInvalidRequestException("unsupported entity category");
+        }
+    }
+
+    private CockpitEntityDetailResponse pipe(String requestId, String id) {
+        PipeNetworkBase v = pipes.getById(id);
+        if (v == null) throw missing();
+        Map<String,Object> a = map(); put(a,"networkType",v.getNetworkType()); put(a,"networkLevel",v.getNetworkLevel());
+        put(a,"managementUnit",v.getManagementUnit()); put(a,"lengthMeters",v.getLength()); put(a,"diameterMillimeters",v.getDiameter());
+        put(a,"material",v.getMaterial()); put(a,"buildDate",v.getBuildDate()); put(a,"remark",v.getRemark());
+        Integer district = CockpitDistrictPrefix.codeOf(v.getNetworkCode());
+        return response(requestId,"PIPE_NETWORK",v.getNetworkId(),v.getNetworkCode(),v.getNetworkName(),v.getStatus(),
+                v.getLocation(),district,new CockpitEntityDetailResponse.Coordinates(v.getStartLongitude(),v.getStartLatitude(),v.getEndLongitude(),v.getEndLatitude()),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse plant(String requestId, String id) {
+        WaterPlantInfo v = plants.getById(id); if (v == null) throw missing();
+        Map<String,Object> a=map(); put(a,"waterSourceType",v.getWaterSourceType()); put(a,"designCapacity",v.getDesignCapacity());
+        put(a,"currentCapacity",v.getCurrentCapacity()); put(a,"treatmentProcess",v.getTreatmentProcess()); put(a,"commissionDate",v.getCommissionDate());
+        put(a,"waterQualityStandard",v.getWaterQualityStandard()); put(a,"principal",v.getPrincipal()); put(a,"contactPhone",v.getContactPhone()); put(a,"remark",v.getRemark());
+        return response(requestId,"WATER_PLANT",String.valueOf(v.getId()),v.getPlantCode(),v.getPlantName(),v.getStatus(),v.getLocation(),null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse station(String requestId, String id) {
+        WaterPumpStation v=stations.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"stationType",v.getStationType()); put(a,"pumpCount",v.getPumpCount()); put(a,"designFlow",v.getDesignFlow());
+        put(a,"designHead",v.getDesignHead()); put(a,"powerCapacity",v.getPowerCapacity()); put(a,"commissionDate",v.getCommissionDate());
+        put(a,"principal",v.getPrincipal()); put(a,"contactPhone",v.getContactPhone()); put(a,"remark",v.getRemark());
+        return response(requestId,"PUMP_STATION",String.valueOf(v.getId()),v.getStationCode(),v.getStationName(),v.getStatus(),v.getLocation(),null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+
+    private CockpitEntityDetailResponse engineeringFacility(String requestId, String id) {
+        EngineeringFacility v = engineeringFacilities.getById(id);
+        if (v == null) throw missing();
+        Map<String,Object> a = map();
+        put(a, "facilityType", v.getFacilityType());
+        put(a, "facilityLevel", v.getFacilityLevel());
+        put(a, "managementUnit", v.getManagementUnit());
+        put(a, "buildDate", v.getBuildDate());
+        put(a, "designFlow", v.getDesignFlow());
+        put(a, "designHead", v.getDesignHead());
+        put(a, "storageCapacity", v.getStorageCapacity());
+        put(a, "equipmentIds", v.getEquipmentId());
+        put(a, "devices", linkedDevices(v.getEquipmentId()));
+        put(a, "remark", v.getRemark());
+        return response(requestId, "ENGINEERING_FACILITY", v.getFacilityId(), v.getFacilityCode(),
+                v.getFacilityName(), v.getStatus(), v.getLocation(), null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(), v.getLatitude(), null, null),
+                a, v.getUpdateTime());
+    }
+
+
+    private java.util.List<Map<String,Object>> linkedDevices(String value) {
+        java.util.List<Map<String,Object>> rows = new java.util.ArrayList<>();
+        if (blank(value)) return rows;
+        for (String rawId : value.split(",")) {
+            String equipmentId = rawId == null ? "" : rawId.trim();
+            if (equipmentId.isEmpty()) continue;
+            EquipmentBase device = equipment.getById(equipmentId);
+            if (device == null) continue;
+            EquipmentStatus status = blank(device.getEquipmentCode())
+                    ? null : equipmentStatuses.getByDeviceCode(device.getEquipmentCode());
+            Map<String,Object> row = map();
+            put(row, "id", device.getEquipmentId());
+            put(row, "code", device.getEquipmentCode());
+            put(row, "name", device.getEquipmentName());
+            put(row, "type", device.getEquipmentTypeName());
+            put(row, "location", device.getEquipmentLocation());
+            if (status != null) {
+                put(row, "currentStatus", status.getCurrentStatus());
+                put(row, "alarmStatus", status.getAlarmStatus());
+                put(row, "onlineStatus", status.getOnlineStatus());
+                put(row, "statusUpdatedAt", status.getStatusUpdateTime());
+            }
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    private CockpitEntityDetailResponse resource(String requestId,String id) {
+        EquipmentBase v=equipment.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"equipmentTypeId",v.getEquipmentTypeId()); put(a,"model",v.getEquipmentModel()); put(a,"specification",v.getEquipmentSpec());
+        put(a,"manufacturer",v.getManufacturer()); put(a,"assetValue",v.getAssetValue()); put(a,"useLifeYears",v.getUseLife()); put(a,"installTime",v.getInstallTime());
+        put(a,"maintainer",v.getMaintainer()); put(a,"maintainerPhone",v.getMaintainerPhone()); put(a,"ownershipUnit",v.getOwnershipUnit()); put(a,"image",v.getEquipmentImage()); put(a,"remark",v.getRemark());
+        return response(requestId,"EMERGENCY_RESOURCE",v.getEquipmentId(),v.getEquipmentCode(),v.getEquipmentName(),null,v.getEquipmentLocation(),CockpitDistrict.codeOf(v.getDistrict()),
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse hazard(String requestId,String id) {
+        HazardHiddenAccount v=hazards.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"hazardType",v.getHazardType()); put(a,"level",v.getHazardLevel()); put(a,"source",v.getHazardSource()); put(a,"description",v.getHazardDesc());
+        put(a,"networkId",v.getNetworkId()); put(a,"equipmentId",v.getEquipmentId()); put(a,"workOrderId",v.getWorkOrderId()); put(a,"reportUnit",v.getReportUnit()); put(a,"reportTime",v.getReportTime());
+        put(a,"rectifyPlan",v.getRectifyPlan()); put(a,"rectifyDeadline",v.getRectifyDeadline()); put(a,"rectifyPerson",v.getRectifyPerson()); put(a,"rectifyFeedback",v.getRectifyFeedback()); put(a,"images",v.getHazardImages()); put(a,"remark",v.getRemark());
+        Integer district=null; if(!blank(v.getEquipmentId())) { EquipmentBase e=equipment.getById(v.getEquipmentId()); if(e!=null) district=CockpitDistrict.codeOf(e.getDistrict()); }
+        return response(requestId,"HAZARD",v.getHazardId(),v.getHazardNo(),v.getHazardDesc(),v.getHazardStatus(),null,district,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse target(String requestId,String id) {
+        RiskAssessment v=risks.getById(id); if(v==null||blank(v.getProtectiveTargets())) throw missing();
+        Map<String,Object>a=map(); put(a,"networkId",v.getNetworkId()); put(a,"riskLevel",v.getRiskLevel()); put(a,"riskScore",v.getRiskScore()); put(a,"riskColor",v.getRiskColor());
+        put(a,"assessmentModel",v.getAssessmentModel()); put(a,"assessmentTime",v.getAssessmentTime()); put(a,"riskFactors",v.getRiskFactors()); put(a,"hazardSources",v.getHazardSources()); put(a,"remark",v.getRemark());
+        return response(requestId,"PROTECTION_TARGET",v.getRiskId(),v.getRiskId(),v.getProtectiveTargets(),v.getRiskLevel(),null,null,null,a,v.getUpdateTime());
+    }
+
+    private static CockpitEntityDetailResponse response(String requestId,String category,String id,String code,String name,String status,String location,Integer district,CockpitEntityDetailResponse.Coordinates coordinates,Map<String,Object> attributes,LocalDateTime updatedAt) {
+        return new CockpitEntityDetailResponse(requestId,category,id,code,name,status,location,district,coordinates,attributes,format(updatedAt));
+    }
+    public CockpitErrorPayload invalidRequest(String requestId) { return error(CockpitErrorCategory.INVALID_REQUEST,"实体详情查询参数不合法",requestId); }
+    public CockpitErrorPayload notFound(String requestId) { return error(CockpitErrorCategory.NOT_FOUND,"实体不存在",requestId); }
+    private static CockpitErrorPayload error(CockpitErrorCategory c,String m,String id) { return new CockpitErrorPayload(c,m,id,format(LocalDateTime.now(BUSINESS_ZONE))); }
+    private static CockpitEntityNotFoundException missing(){return new CockpitEntityNotFoundException("entity not found");}
+    private static Map<String,Object> map(){return new LinkedHashMap<>();}
+    private static void put(Map<String,Object> map,String key,Object value) {
+        if (value != null) {
+            map.put(key, value);
+        }
+    }
+    private static String format(LocalDateTime value){return value==null?null:DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atZone(BUSINESS_ZONE));}
+    private static boolean blank(String v){return v==null||v.trim().isEmpty();}
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityNotFoundException.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.entity;
+
+public class CockpitEntityNotFoundException extends RuntimeException {
+    public CockpitEntityNotFoundException(String message) { super(message); }
+}

+ 2 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java

@@ -354,6 +354,7 @@ public class BusinessCockpitEventAdapter {
         EquipmentBase device = facts.devicesByCode.get(normalize(alarm.getDeviceCode()));
         return new CockpitCurrentEventsSnapshot.SourceAlarm(
                 trimToNull(alarm.getId()),
+                trimToNull(alarm.getAlarmNo()),
                 firstNonBlank(warningTypeName(metadata), alarm.getWarningCode()),
                 null,
                 level(alarm.getAlarmLevel()),
@@ -442,6 +443,7 @@ public class BusinessCockpitEventAdapter {
             EquipmentBase device = facts.devicesByCode.get(normalize(alarm.getDeviceCode()));
             result.add(new CockpitHistoryAnalysisSnapshot.SourceAlarm(
                     trimToNull(alarm.getId()),
+                    trimToNull(alarm.getAlarmNo()),
                     level(alarm.getAlarmLevel()),
                     offsetTime(alarm.getCreateTime()),
                     typeCode(alarm.getDeviceCode(), facts),

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsResponse.java

@@ -160,6 +160,8 @@ public class CockpitCurrentEventsResponse {
 
     public static class EventItem {
         private final String eventNo;
+        /** 业务编号:预警 warningNo / 报警 alarmNo。界面上屏用它,eventNo 只作内部键。 */
+        private final String eventCode;
         private final String eventName;
         private final String eventType;
         private final Integer level;
@@ -184,6 +186,7 @@ public class CockpitCurrentEventsResponse {
 
         public EventItem(
                 String eventNo,
+                String eventCode,
                 String eventName,
                 String eventType,
                 Integer level,
@@ -206,6 +209,7 @@ public class CockpitCurrentEventsResponse {
                 BigDecimal thresholdMaxValue,
                 WorkOrderSummary workOrderSummary) {
             this.eventNo = eventNo;
+            this.eventCode = eventCode;
             this.eventName = eventName;
             this.eventType = eventType;
             this.level = level;
@@ -230,6 +234,8 @@ public class CockpitCurrentEventsResponse {
         }
 
         public String getEventNo() { return eventNo; }
+
+        public String getEventCode() { return eventCode; }
         public String getEventName() { return eventName; }
         public String getEventType() { return eventType; }
         public Integer getLevel() { return level; }

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsService.java

@@ -206,6 +206,7 @@ public class CockpitCurrentEventsService {
                 continue;
             }
             rows.add(new EventRow(
+                    warning.getWarningNo(),
                     warning.getWarningNo(),
                     warning.getWarningName(),
                     warning.getWarningType(),
@@ -260,6 +261,7 @@ public class CockpitCurrentEventsService {
             }
             rows.add(new EventRow(
                     alarm.getAlarmId(),
+                    alarm.getAlarmNo(),
                     alarm.getWarningName(),
                     alarm.getWarningType(),
                     alarm.getLevel(),
@@ -322,6 +324,12 @@ public class CockpitCurrentEventsService {
         return result;
     }
 
+    /** 首选非空:展示编号缺失时退回内部键(不编造、不留空)。 */
+    private static String firstNonBlank(String preferred, String fallback) {
+        if (preferred != null && !preferred.trim().isEmpty()) return preferred.trim();
+        return fallback == null ? null : fallback.trim();
+    }
+
     private static String stateName(Integer stateCode) {
         return CockpitWorkOrderStateDictionary.byCode(stateCode) == null
                 ? null
@@ -448,6 +456,8 @@ public class CockpitCurrentEventsService {
 
     private static class EventRow {
         private final String eventNo;
+        /** 上屏用的业务编号(预警 warningNo / 报警 alarmNo);与内部键 eventNo 分开。 */
+        private final String eventCode;
         private final String eventName;
         private final String eventType;
         private final Integer level;
@@ -470,6 +480,7 @@ public class CockpitCurrentEventsService {
 
         private EventRow(
                 String eventNo,
+                String eventCode,
                 String eventName,
                 String eventType,
                 Integer level,
@@ -490,6 +501,7 @@ public class CockpitCurrentEventsService {
                 BigDecimal thresholdMinValue,
                 BigDecimal thresholdMaxValue) {
             this.eventNo = eventNo;
+            this.eventCode = eventCode;
             this.eventName = eventName;
             this.eventType = eventType;
             this.level = level;
@@ -520,6 +532,7 @@ public class CockpitCurrentEventsService {
             String key = eventNo == null ? null : eventNo.trim();
             return new CockpitCurrentEventsResponse.EventItem(
                     eventNo,
+                    firstNonBlank(eventCode, eventNo),
                     eventName,
                     eventType,
                     level,

+ 37 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsSnapshot.java

@@ -233,6 +233,8 @@ public class CockpitCurrentEventsSnapshot {
 
     public static class SourceAlarm {
         private final String alarmId;
+        /** 业务编号(alarm_no):上屏用它,主键只用于内部关联。 */
+        private final String alarmNo;
         private final String warningName;
         private final String warningType;
         private final Integer level;
@@ -255,6 +257,7 @@ public class CockpitCurrentEventsSnapshot {
         /** 报警片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
         private final Integer district;
 
+        /** 兼容重载:不传业务编号时退回主键(既有测试/旧适配器用)。 */
         public SourceAlarm(
                 String alarmId,
                 String warningName,
@@ -277,7 +280,37 @@ public class CockpitCurrentEventsSnapshot {
                 BigDecimal thresholdMinValue,
                 BigDecimal thresholdMaxValue,
                 Integer district) {
+            this(alarmId, null, warningName, warningType, level, status, eventTime, updatedTime,
+                    value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
+                    location, typeCode, longitude, latitude, coordinateVerified,
+                    thresholdMinValue, thresholdMaxValue, district);
+        }
+
+        public SourceAlarm(
+                String alarmId,
+                String alarmNo,
+                String warningName,
+                String warningType,
+                Integer level,
+                String status,
+                String eventTime,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                String deviceCode,
+                String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
+                String location,
+                String typeCode,
+                BigDecimal longitude,
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue,
+                Integer district) {
             this.alarmId = alarmId;
+            this.alarmNo = alarmNo;
             this.warningName = warningName;
             this.warningType = warningType;
             this.level = level;
@@ -304,6 +337,10 @@ public class CockpitCurrentEventsSnapshot {
             return alarmId;
         }
 
+        public String getAlarmNo() {
+            return alarmNo;
+        }
+
         public String getWarningName() {
             return warningName;
         }

+ 138 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitEventDetailService.java

@@ -0,0 +1,138 @@
+package com.zksy.cockpit.event;
+
+import com.zksy.base.alarm.domain.AlarmLog;
+import com.zksy.base.alarm.dto.AlarmVO;
+import com.zksy.base.alarm.mapper.AlarmLogMapper;
+import com.zksy.base.alarm.mapper.AlarmOperationsMapper;
+import com.zksy.base.warning.domain.WarningSupervision;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.mapper.EarlyWarningMapper;
+import com.zksy.base.warning.mapper.WarningAttachmentMapper;
+import com.zksy.base.warning.mapper.WarningDisposalMapper;
+import com.zksy.base.warning.mapper.WarningSupervisionMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.warning.domain.WarningAttachment;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 驾驶舱只读事件详情(维护者 2026-10-29)。
+ *
+ * <p>为什么不能直接复用移动端 {@code getMobileWarningDetail} / {@code mobileDetail}:
+ * 那两个方法都**按当前登录用户的可见范围**过滤({@code WarningAccessService.requireVisible}
+ * 与报警 SQL 的 {@code mobileScope}),而驾驶舱 REST 是免鉴权的只读域 —— 没有登录用户时
+ * 调用只会抛「获取用户账户异常」或落到 {@code AND 1 = 0} 返回空。
+ *
+ * <p>所以这里只读**底层业务表**,不做范围裁剪:驾驶舱本身就是全量只读大屏,
+ * 既保住"数据来自真实表"(没有伪造字段),也不会把鉴权失败当成数据缺失。
+ * 写操作(发布/接收/处置/审核)一律不在此提供 —— 免鉴权域绝不放写入口。
+ */
+@Service
+@RequiredArgsConstructor
+public class CockpitEventDetailService {
+
+    private final EarlyWarningMapper warnings;
+    private final AlarmOperationsMapper alarms;
+    private final AlarmLogMapper alarmLogs;
+    private final WarningDisposalMapper disposals;
+    private final WarningAttachmentMapper attachments;
+    private final WarningSupervisionMapper supervisions;
+    private final AlarmWarningRelMapper linkedAlarms;
+
+    /**
+     * 预警详情:基本信息 + 处置记录 + 附件 + 督办 + 关联报警(各段独立成败)。
+     *
+     * <p>{@code eventId} 可能是**业务编号**(列表下发的 warning_no)或主键(warning_id):
+     * 驾驶舱列表只带编号,先按编号回查,再退回按主键 —— 两条口径都认,不让前端改拿主键。
+     */
+    public Map<String, Object> warningDetail(String eventId) {
+        if (eventId == null || eventId.trim().isEmpty()) {
+            return null;
+        }
+        String key = eventId.trim();
+        Map<String, Object> basicInfo = warnings.selectWarningDetailByNo(key);
+        if (basicInfo == null) {
+            basicInfo = warnings.selectWarningDetail(key);
+        }
+        if (basicInfo == null) {
+            return null;
+        }
+        // 后续各分区都按**主键**取(业务编号只用于定位这一行)。
+        String warningId = String.valueOf(basicInfo.get("warning_id") == null ? key : basicInfo.get("warning_id"));
+        // SQL 返回的是**下划线列名**的 Map(warning_no / warning_level …),而驾驶舱其它端点
+        // 一律驼峰;这里统一转一次,前端只按一套键读,不在前端做第二套兼容。
+        Map<String, Object> camel = camelize(basicInfo);
+        Map<String, Object> result = new HashMap<>();
+        result.put("basicInfo", camel);
+        result.put("disposalHistory", safe(() -> disposals.selectDisposalList(warningId)));
+        result.put("attachmentList", safe(() -> attachments.selectList(
+                new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getWarningId, warningId))));
+        result.put("supervisionList", safe(() -> supervisions.selectByWarningId(warningId),
+                Collections.<WarningSupervision>emptyList()));
+        result.put("alarmList", safe(() -> linkedAlarms.selectAlarmListByWarningId(warningId)));
+        return result;
+    }
+
+    /**
+     * 报警详情:与移动端同一份 VO(设备名/位置/类型/工单/单位都由同一条 SQL 回填)。
+     *
+     * <p>{@code eventId} 可能是**业务编号**(列表下发的 alarm_no)或主键:先按编号回查,
+     * 再退回按主键 —— 两条口径都认,前端不必改拿主键。
+     */
+    public AlarmVO alarmDetail(String eventId) {
+        if (eventId == null || eventId.trim().isEmpty()) {
+            return null;
+        }
+        String key = eventId.trim();
+        AlarmVO detail = alarms.selectCockpitDetailByNo(key);
+        return detail != null ? detail : alarms.selectCockpitDetail(key);
+    }
+
+    /** 报警处置流程(与详情分开取,失败只影响这一步)。 */
+    public List<AlarmLog> alarmLogs(String alarmId) {
+        return safe(() -> alarmLogs.selectList(
+                new LambdaQueryWrapper<AlarmLog>().eq(AlarmLog::getAlarmId, alarmId)
+                        .orderByAsc(AlarmLog::getOperTime)));
+    }
+
+    /** 下划线列名 → 驼峰键(SQL Map 与 Java Bean 输出口径对齐)。 */
+    private static Map<String, Object> camelize(Map<String, Object> source) {
+        Map<String, Object> out = new HashMap<>();
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            String key = entry.getKey();
+            if (key == null) continue;
+            StringBuilder name = new StringBuilder();
+            boolean upper = false;
+            for (char ch : key.toCharArray()) {
+                if (ch == '_') { upper = true; continue; }
+                name.append(upper ? Character.toUpperCase(ch) : ch);
+                upper = false;
+            }
+            out.put(name.toString(), entry.getValue());
+        }
+        return out;
+    }
+
+    private static <T> T safe(ThrowingSupplier<T> supplier) {
+        return safe(supplier, null);
+    }
+
+    private static <T> T safe(ThrowingSupplier<T> supplier, T fallback) {
+        try {
+            T value = supplier.get();
+            return value == null ? fallback : value;
+        } catch (RuntimeException error) {
+            return fallback;
+        }
+    }
+
+    @FunctionalInterface
+    private interface ThrowingSupplier<T> {
+        T get();
+    }
+}

+ 2 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java

@@ -166,6 +166,7 @@ public class CockpitHistoryAnalysisResponse {
     public static class EventItem extends CockpitCurrentEventsResponse.EventItem {
         public EventItem(
                 String eventNo,
+                String eventCode,
                 String eventName,
                 String eventType,
                 Integer level,
@@ -187,7 +188,7 @@ public class CockpitHistoryAnalysisResponse {
                 BigDecimal thresholdMinValue,
                 BigDecimal thresholdMaxValue,
                 CockpitCurrentEventsResponse.WorkOrderSummary workOrderSummary) {
-            super(eventNo, eventName, eventType, level, levelColor, status, eventTime, updatedTime,
+            super(eventNo, eventCode, eventName, eventType, level, levelColor, status, eventTime, updatedTime,
                     value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
                     location, industry, longitude, latitude, coordinateValidation,
                     thresholdMinValue, thresholdMaxValue, workOrderSummary);

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java

@@ -245,6 +245,7 @@ public class CockpitHistoryAnalysisService {
                 continue;
             }
             rows.add(new EventRow(
+                    warning.getWarningNo(),
                     warning.getWarningNo(),
                     warning.getWarningNo(),
                     warning.getTypeCode(),
@@ -287,6 +288,7 @@ public class CockpitHistoryAnalysisService {
             }
             rows.add(new EventRow(
                     alarm.getAlarmId(),
+                    firstNonBlank(alarm.getAlarmNo(), alarm.getAlarmId()),
                     isBlank(alarm.getEventName()) ? alarm.getAlarmId() : alarm.getEventName(),
                     alarm.getTypeCode(),
                     alarm.getLevel(),
@@ -550,10 +552,17 @@ public class CockpitHistoryAnalysisService {
         return value == null || value.trim().isEmpty();
     }
 
+    /** 首选非空(业务编号优先,缺失时退回内部键 —— 不编造、不留空)。 */
+    private static String firstNonBlank(String preferred, String fallback) {
+        if (!isBlank(preferred)) return preferred.trim();
+        return isBlank(fallback) ? null : fallback.trim();
+    }
+
     /** 逐事件行(领域内形状):聚合与逐事件清单共用一份事实。 */
     private static class EventRow {
         private final String eventNo;
         private final String eventName;
+        private final String eventCode;
         private final String eventType;
         private final Integer level;
         private final String status;
@@ -570,6 +579,7 @@ public class CockpitHistoryAnalysisService {
 
         private EventRow(
                 String eventNo,
+                String eventCode,
                 String eventName,
                 String eventType,
                 Integer level,
@@ -585,6 +595,7 @@ public class CockpitHistoryAnalysisService {
                 BigDecimal minValue,
                 BigDecimal maxValue) {
             this.eventNo = eventNo;
+            this.eventCode = eventCode;
             this.eventName = eventName;
             this.eventType = eventType;
             this.level = level;
@@ -609,6 +620,7 @@ public class CockpitHistoryAnalysisService {
         private CockpitHistoryAnalysisResponse.EventItem toItem() {
             return new CockpitHistoryAnalysisResponse.EventItem(
                     eventNo,
+                    eventCode,
                     isBlank(eventName) ? eventNo : eventName,
                     eventType,
                     level,

+ 22 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java

@@ -239,6 +239,8 @@ public class CockpitHistoryAnalysisSnapshot {
 
     public static class SourceAlarm {
         private final String alarmId;
+        /** 业务编号(alarm_no):上屏用它,主键只用于内部关联(维护者 2026-10-29)。 */
+        private final String alarmNo;
         private final Integer level;
         private final String eventTime;
         private final String typeCode;
@@ -255,20 +257,31 @@ public class CockpitHistoryAnalysisSnapshot {
         /** 报警片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
         private final Integer district;
 
-        /** 聚合最小字段(既有测试/空适配器用)。 */
+        /** 兼容重载:不带业务编号时退回主键(既有测试/旧适配器用)。 */
         public SourceAlarm(
                 String alarmId,
                 Integer level,
                 String eventTime,
                 String typeCode,
-                String deviceCode) {
-            this(alarmId, level, eventTime, typeCode, deviceCode,
-                    null, null, null, null, null, null, null, null, null, null);
+                String deviceCode,
+                String eventName,
+                String status,
+                String deviceName,
+                String location,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                BigDecimal minValue,
+                BigDecimal maxValue,
+                Integer district) {
+            this(alarmId, null, level, eventTime, typeCode, deviceCode, eventName, status,
+                    deviceName, location, updatedTime, value, unit, minValue, maxValue, district);
         }
 
         /** 票 61:逐事件行展示字段(业务适配器用)。 */
         public SourceAlarm(
                 String alarmId,
+                String alarmNo,
                 Integer level,
                 String eventTime,
                 String typeCode,
@@ -284,6 +297,7 @@ public class CockpitHistoryAnalysisSnapshot {
                 BigDecimal maxValue,
                 Integer district) {
             this.alarmId = alarmId;
+            this.alarmNo = alarmNo;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
@@ -304,6 +318,10 @@ public class CockpitHistoryAnalysisSnapshot {
             return alarmId;
         }
 
+        public String getAlarmNo() {
+            return alarmNo;
+        }
+
         public Integer getLevel() {
             return level;
         }

+ 11 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureOperatingInformationAdapter.java

@@ -76,7 +76,7 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
                     plant.getDesignCapacity(),
                     sampledAt,
                     abnormalStatus(plant.getStatus()),
-                    plant.getPlantCode()));
+                    plant.getPlantCode(), String.valueOf(plant.getId()), "WATER_PLANT", plant.getStatus()));
         }
 
         for (WaterPumpStation station : safeList(pumpStationService.list(
@@ -102,7 +102,7 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
                     null,
                     sampledAt,
                     abnormalStatus(station.getStatus()),
-                    station.getStationCode()));
+                    station.getStationCode(), String.valueOf(station.getId()), "PUMP_STATION", station.getStatus()));
         }
 
         for (PipeNetworkBase pipe : safeList(pipeNetworkBaseService.list())) {
@@ -124,7 +124,7 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
                     null,
                     sampledAt,
                     abnormalStatus(pipe.getStatus()),
-                    pipe.getNetworkCode()));
+                    pipe.getNetworkCode(), pipe.getNetworkId(), "PIPE_NETWORK", pipe.getStatus()));
         }
 
         for (EngineeringFacility facility : safeList(engineeringFacilityService.list())) {
@@ -148,7 +148,7 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
                     null,
                     sampledAt,
                     false,
-                    facility.getFacilityId()));
+                    facility.getFacilityId(), facility.getFacilityId(), "ENGINEERING_FACILITY", facility.getStatus()));
         }
 
         return new CockpitInfrastructureOperatingInformationSnapshot(latestUpdatedAt, metrics);
@@ -164,7 +164,10 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
             BigDecimal capacityValue,
             String sampledAt,
             boolean abnormal,
-            String pointCode) {
+            String pointCode,
+            String facilityId,
+            String facilityKind,
+            String facilityStatus) {
         return new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
                 code,
                 name,
@@ -176,6 +179,9 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
                 sampledAt,
                 abnormal,
                 pointCode == null ? Collections.emptyList() : Collections.singletonList(pointCode.trim()),
+                facilityId,
+                facilityKind,
+                facilityStatus,
                 Collections.emptyList());
     }
 

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationResponse.java

@@ -211,6 +211,9 @@ public class CockpitInfrastructureOperatingInformationResponse {
         private final List<TrendPoint> trend;
         private final boolean abnormal;
         private final List<String> pointCodes;
+        private final String facilityId;
+        private final String facilityKind;
+        private final String facilityStatus;
         /**
          * 指标所属设施的片区(票 81):设施没有片区列,按**设施编码前缀**由
          * {@code CockpitDistrictPrefix} 做后端唯一映射;推不出时为 {@code null}=未分区。
@@ -253,6 +256,15 @@ public class CockpitInfrastructureOperatingInformationResponse {
                 List<String> pointCodes,
                 Integer district,
                 String districtName) {
+            this(metricCode, metricName, category, unit, value, capacityValue, loadRatio, loadState, sampledAt, freshness,
+                    trend, abnormal, pointCodes, district, districtName, null, null, null);
+        }
+
+        public OperatingMetric(
+                String metricCode, String metricName, String category, String unit, BigDecimal value,
+                BigDecimal capacityValue, BigDecimal loadRatio, String loadState, String sampledAt,
+                MetricFreshness freshness, List<TrendPoint> trend, boolean abnormal, List<String> pointCodes,
+                Integer district, String districtName, String facilityId, String facilityKind, String facilityStatus) {
             this.metricCode = metricCode;
             this.metricName = metricName;
             this.category = category;
@@ -266,6 +278,9 @@ public class CockpitInfrastructureOperatingInformationResponse {
             this.trend = trend;
             this.abnormal = abnormal;
             this.pointCodes = pointCodes;
+            this.facilityId = facilityId;
+            this.facilityKind = facilityKind;
+            this.facilityStatus = facilityStatus;
             this.district = district;
             this.districtName = districtName;
         }
@@ -286,6 +301,9 @@ public class CockpitInfrastructureOperatingInformationResponse {
         public List<TrendPoint> getTrend() { return trend; }
         public boolean isAbnormal() { return abnormal; }
         public List<String> getPointCodes() { return pointCodes; }
+        public String getFacilityId() { return facilityId; }
+        public String getFacilityKind() { return facilityKind; }
+        public String getFacilityStatus() { return facilityStatus; }
         public Integer getDistrict() { return district; }
         public String getDistrictName() { return districtName; }
     }

+ 4 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java

@@ -210,7 +210,10 @@ public class CockpitInfrastructureOperatingInformationService {
                 abnormal,
                 pointCodes(source),
                 districtOf(source),
-                CockpitDistrict.nameOf(districtOf(source)));
+                CockpitDistrict.nameOf(districtOf(source)),
+                source.getFacilityId(),
+                source.getFacilityKind(),
+                source.getFacilityStatus());
     }
 
     private static List<CockpitInfrastructureOperatingInformationResponse.TrendPoint> trend(

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationSnapshot.java

@@ -26,6 +26,9 @@ public class CockpitInfrastructureOperatingInformationSnapshot {
         private final String sampledAt;
         private final boolean abnormal;
         private final List<String> pointCodes;
+        private final String facilityId;
+        private final String facilityKind;
+        private final String facilityStatus;
         private final List<SourcePoint> trend;
 
         public SourceMetric(
@@ -40,6 +43,15 @@ public class CockpitInfrastructureOperatingInformationSnapshot {
                 boolean abnormal,
                 List<String> pointCodes,
                 List<SourcePoint> trend) {
+            this(metricCode, metricName, category, unit, industry, value, capacityValue, sampledAt, abnormal,
+                    pointCodes, null, null, null, trend);
+        }
+
+        public SourceMetric(
+                String metricCode, String metricName, String category, String unit, String industry,
+                BigDecimal value, BigDecimal capacityValue, String sampledAt, boolean abnormal,
+                List<String> pointCodes, String facilityId, String facilityKind, String facilityStatus,
+                List<SourcePoint> trend) {
             this.metricCode = metricCode;
             this.metricName = metricName;
             this.category = category;
@@ -50,6 +62,9 @@ public class CockpitInfrastructureOperatingInformationSnapshot {
             this.sampledAt = sampledAt;
             this.abnormal = abnormal;
             this.pointCodes = pointCodes;
+            this.facilityId = facilityId;
+            this.facilityKind = facilityKind;
+            this.facilityStatus = facilityStatus;
             this.trend = trend;
         }
 
@@ -63,6 +78,9 @@ public class CockpitInfrastructureOperatingInformationSnapshot {
         public String getSampledAt() { return sampledAt; }
         public boolean isAbnormal() { return abnormal; }
         public List<String> getPointCodes() { return pointCodes; }
+        public String getFacilityId() { return facilityId; }
+        public String getFacilityKind() { return facilityKind; }
+        public String getFacilityStatus() { return facilityStatus; }
         public List<SourcePoint> getTrend() { return trend; }
     }
 

+ 1 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java

@@ -326,6 +326,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
             WorkOrder order, String eventSource, String eventCode, boolean associatedWithCurrentEvent) {
         CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(order.getOrderStatus());
         return new CockpitWorkOrderSummary(
+                order.getOrderId(),
                 order.getOrderNo(),
                 resolveDeviceCode(order.getDeviceId()),
                 order.getOrderLevel(),

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderSummary.java

@@ -1,6 +1,9 @@
 package com.zksy.cockpit.workorder;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
 public class CockpitWorkOrderSummary {
+    private final Long orderId;
     private final String workOrderCode;
     private final String deviceCode;
     private final Integer orderLevel;
@@ -16,6 +19,7 @@ public class CockpitWorkOrderSummary {
     private final boolean associatedWithCurrentEvent;
 
     public CockpitWorkOrderSummary(
+            Long orderId,
             String workOrderCode,
             String deviceCode,
             Integer orderLevel,
@@ -28,6 +32,7 @@ public class CockpitWorkOrderSummary {
             String realFinishTime,
             CockpitWorkOrderEventAssociation eventAssociation,
             boolean associatedWithCurrentEvent) {
+        this.orderId = orderId;
         this.workOrderCode = workOrderCode;
         this.deviceCode = deviceCode;
         this.orderLevel = orderLevel;
@@ -42,6 +47,14 @@ public class CockpitWorkOrderSummary {
         this.associatedWithCurrentEvent = associatedWithCurrentEvent;
     }
 
+    /**
+     * 数据库主键,仅供服务端内部关联使用,**不进 JSON**。
+     *
+     * <p>驾驶舱 REST 读模型与 WS 增量帧同一口径:不泄漏数据库主键
+     * (见 {@code CockpitWorkOrderDeltaPushTaskTest} 的「增量帧不泄漏数据库主键(REST 读模型同口径)」)。
+     */
+    @JsonIgnore
+    public Long getOrderId() { return orderId; }
     public String getWorkOrderCode() { return workOrderCode; }
     public String getDeviceCode() { return deviceCode; }
     public Integer getOrderLevel() { return orderLevel; }

+ 10 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml

@@ -88,6 +88,16 @@
         SELECT <include refid="columns"/> <include refid="joins"/> WHERE a.id = #{id}
         <include refid="com.zksy.base.alarm.mapper.AlarmReviewMapper.scope"/>
     </select>
+    <!-- 驾驶舱免鉴权只读域用:不按登录用户范围裁剪(范围条件在 scope 片段里)。 -->
+    <select id="selectCockpitDetail" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="joins"/> WHERE a.id = #{id}
+    </select>
+    <!-- 驾驶舱列表下发的是业务编号(alarm_no),详情按编号回查;同样不按用户范围裁剪。 -->
+    <select id="selectCockpitDetailByNo" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="joins"/> WHERE a.alarm_no = #{alarmNo}
+        ORDER BY a.create_time DESC NULLS LAST
+        LIMIT 1
+    </select>
     <select id="selectReminders" resultMap="alarm">
         SELECT <include refid="columns"/>, l.oper_desc AS latest_operation,
             COALESCE(l.oper_time, a.update_time, a.create_time) AS latest_operation_time

+ 22 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml

@@ -1099,6 +1099,28 @@
         WHERE w.warning_id = #{warningId}
     </select>
 
+    <!-- 驾驶舱只读详情用:列表下发的是**业务编号**(warning_no),详情要的是主键(warning_id)。
+         两条口径都在契约里,因此按编号回查一次,不要求前端改拿主键。 -->
+    <select id="selectWarningDetailByNo" resultType="java.util.Map">
+        SELECT w.*, (SELECT wt.warning_name FROM app_user.warning_type wt WHERE wt.warning_code=w.warning_type ORDER BY wt.id LIMIT 1) AS warning_type_name,
+            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='alert_level' AND dict_value=w.warning_level AND status='0' ORDER BY dict_sort LIMIT 1) AS warning_level_name,
+            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name,
+            COALESCE(NULLIF(TRIM(hu.nick_name), ''), w.handler) AS handler_name,
+            COALESCE(NULLIF(TRIM(ru.nick_name), ''), w.reviewer) AS reviewer_name,
+            COALESCE(NULLIF(TRIM(pu.nick_name), ''), w.publisher) AS publisher_name,
+            COALESCE(NULLIF(TRIM(cu.nick_name), ''), w.create_by) AS creator_name,
+            COALESCE(eb.equipment_location, w.location) AS location
+        FROM early_warning w
+        LEFT JOIN app_user.sys_user hu ON hu.user_name = w.handler
+        LEFT JOIN app_user.sys_user ru ON ru.user_name = w.reviewer
+        LEFT JOIN app_user.sys_user pu ON pu.user_name = w.publisher
+        LEFT JOIN app_user.sys_user cu ON cu.user_name = w.create_by
+        LEFT JOIN app_user.equipment_base eb ON eb.equipment_code = w.device_code
+        WHERE w.warning_no = #{warningNo}
+        ORDER BY w.create_time DESC NULLS LAST
+        LIMIT 1
+    </select>
+
     <select id="selectStatistics" resultType="java.util.Map">
         SELECT
             COUNT(*) AS total,