Przeglądaj źródła

Merge remote-tracking branch 'origin/master'

null 1 dzień temu
rodzic
commit
56784debdc
28 zmienionych plików z 1854 dodań i 74 usunięć
  1. 5 3
      docs/cockpit/README.md
  2. 45 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitDeviceFacilityRelationController.java
  3. 5 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisController.java
  4. 34 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadController.java
  5. 9 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitContractIntegrationTest.java
  6. 178 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDeviceFacilityRelationContractTest.java
  7. 96 6
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java
  8. 27 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  9. 32 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java
  10. 210 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderListContractTest.java
  11. 44 16
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  12. 67 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java
  13. 208 42
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java
  14. 121 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java
  15. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  16. 9 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  17. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  18. 59 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java
  19. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  20. 17 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java
  21. 130 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationResponse.java
  22. 249 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationService.java
  23. 56 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  24. 69 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderListResponse.java
  25. 37 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderListSnapshot.java
  26. 28 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadAdapter.java
  27. 75 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadService.java
  28. 10 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/EmptyCockpitWorkOrderReadAdapter.java

+ 5 - 3
docs/cockpit/README.md

@@ -37,11 +37,11 @@
 | 基础设施 | `GET /api/cockpit/v1/infrastructure/operating-information` | `industry` `range` `from` `to` |
 | 基础设施 | `GET /api/cockpit/v1/infrastructure/management-information` | `industry` `district` |
 | 基础设施 | `GET /api/cockpit/v1/infrastructure/emergency-support` | `category` `industry` `area` |
-| 运行监测 | `GET /api/cockpit/v1/monitoring/overview` | `industry` `deviceType` |
+| 运行监测 | `GET /api/cockpit/v1/monitoring/overview` | `industry` `deviceType`;响应含逐设备台账 `devices[]` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/distribution` | `industry` `deviceType` `pointStatus` `district` `page` `pageSize` `snapshotAt` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/situation` | `industry` `deviceType` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/current` | `mode`(必填) `level` `industry` `status` `page` `pageSize` |
-| 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` |
+| 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/handling-efficiency` | `eventFamily`(必填) `industry` `level` `workOrderState` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/multidimensional-analysis` | `eventFamily`(必填) `industry` `level` `eventType` `cause` `deviceType` `district` `startTime` `endTime` |
 
@@ -67,6 +67,7 @@
 - 地图(跨四个模式常驻的中央一张图)只从 `map-snapshot` 取数;`/monitoring/distribution` 是运行监测模式「监测分布」面板的载荷,不作为地图取数路径。
 - `map-snapshot` 的目标契约接受 `industry`、`district`、`deviceType` 三个筛选入参;分页与 `snapshotAt` 语义不因筛选改变。**2026-09-24 人工裁定**:正式行业扩为 `GAS` / `WATER_SUPPLY` / `DRAINAGE` / `MANHOLE`(井盖为独立根类型);对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。`monitoring/overview`、`monitoring/situation`、`map-snapshot`、`monitoring/distribution`、事件和基础设施服务均按该四行业口径校验;对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。旧 `type_id/parent_type_id` 不是当前 schema 字段。
 - 服务端完成过滤,并在响应 `mapFilter{industry,district,districtName,districtFilter,deviceType,pointCodes,deviceCodes}` 回显**实际生效范围**(编码为过滤后全量、分页前,升序)。
+- **`snapshotAt` 回显(执行票 62 补齐)**:`monitoring/distribution` 的 `data.snapshotAt` 回显本次响应依据的快照标记,与 `map-snapshot` 同形同义。该端点 `page > 1` 时**必须**带非空 `snapshotAt`(不带即 400 `INVALID_REQUEST`「监测分布分页参数不合法」)——此前响应不回显该值,调用方只能自己找时间戳猜,跨页对齐无从保证;现在第 1 页取回什么、后续页就原样回带什么。`page = 1` 可不带(后端据此开新快照)。
 - `coordinateValidation` 汇总是**当前筛选范围内的全量口径**(再切片分页);逐点/逐设备保留 `renderable=false` 的对象。
 - 逐点 `industry` 由类型推导(口径同 distribution:类型可解析、`objectKind = SMART_DEVICE`、行业属正式行业);不可推导时为 `null`,**默认不排除对象**,仅在请求了 `industry`/`deviceType` 过滤时按推导结果筛选。
 
@@ -95,8 +96,9 @@
 - `overview/warning-settings` 的趋势桶是**业务时区日期**(`yyyy-MM-dd`),声明窗口时按「桶日期落在窗口起止日期之间(含端点)」筛选,**桶顺序沿用适配器顺序**;桶标签无法解析时保留该桶,不静默丢数据。
 - 未知类型、非智能设备、非正式行业在**按行业/类型筛选时**显式排除,不做静默归类;`map-snapshot` 不带筛选时仍返回全部对象(`industry`/`typeName` 为 `null`),保证「全城底图」不因类型数据缺失而丢对象。
 - 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标结构非法、越界或落在主场景区外时 `renderable = false` 且给出 `reason`。
+- `monitoring/overview` 的 `devices[]` 是**逐设备台账行**(2026-10-22 追加):每行 = `deviceCode` / `deviceName` / `deviceType{code,name}` / `industry` / `monitoringState` / `monitoringPointCode` / `sampledAt` / `createdAt`,顺序为后端遍历顺序(稳定,调用方不重排)。**行数恒等于 `counts.equipmentCount`**(同一遍历产出,不是第二个口径),筛选(`industry`/`deviceType`)生效时两者同步收窄。它与 `monitoring/situation` 的 `abnormalEquipment[]` **不是一回事**:后者只装异常四态(WARNING/ALARM/EXPIRED/OFFLINE)设备,是「异常清单」;前者是**全部**智能设备的台账。缺值一律 `null`(`deviceName`/`monitoringPointCode`/`createdAt`),不拿编码冒充、不填 0。
 - 事件坐标按同一自动准入判定(不再依赖 `coordinateVerified`):区内且数值合法 → `VALID` / `renderable=true`。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
-- `events/history-analysis` 的 `rows[]` 是**小时桶聚合行**,不存在逐事件行,因此不携带 `coordinateValidation` 与阈值字段(逐事件坐标只在 `events/current` 提供)。
+- `events/history-analysis` 同时返回两块内容:`rows[]` 是**小时桶聚合行**(趋势与行级处置率口径,不携带逐事件坐标/阈值);`events[]` 是**逐事件行**(票 61 新增,形状与 `events/current` 的 `EventItem` 同构),按 `eventTime` 倒序、同时间按 `eventNo` 升序,由 `page`/`pageSize`(默认 1/100,上限 1000)分页,元数据在 `pagination` 中,`pagination.total` 即窗口内事件总数。逐事件坐标仍只在 `events/current` 提供,历史逐事件行的 `coordinateValidation` 为 `null`。
 
 ### 监测点详情设备坐标
 

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

@@ -0,0 +1,45 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationService;
+import com.zksy.common.core.domain.AjaxResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.RestController;
+
+/**
+ * 设备 → 设施真实关联(免鉴权只读,`/api/cockpit/v1/**` GET 已由 SecurityConfig 放行)。
+ *
+ * <p>存在意义:前端「地下管线详情视图」此前拿不到真实设施关系,只能把设备按同业邻近摆成「示意安装」。
+ * 本端点把 {@code manhole_device_rel} / {@code gas_pipe_point_device_rel} 的真实主键关系如实吐出,
+ * 让前端按主键把设备挂到管段上;查不到就是空数组,前端继续降级,绝不在这里补关系。
+ *
+ * <p>只读:纯查询,无写库、无事务注解。
+ */
+@RestController
+public class CockpitDeviceFacilityRelationController {
+    private static final Logger LOG = LoggerFactory.getLogger(CockpitDeviceFacilityRelationController.class);
+
+    private final CockpitDeviceFacilityRelationService service;
+
+    public CockpitDeviceFacilityRelationController(CockpitDeviceFacilityRelationService service) {
+        this.service = service;
+    }
+
+    @GetMapping("/api/cockpit/v1/device-facility-relations")
+    public ResponseEntity<AjaxResult> relations() {
+        return ResponseEntity.ok(AjaxResult.success(service.load()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(CockpitReadException exception) {
+        // 失败返回错误信封而不是空数组:空数组对前端意味着「确实没有真实关联」。
+        LOG.error("Device facility relation read failed", exception);
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(AjaxResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.getMessage()));
+    }
+}

+ 5 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisController.java

@@ -34,6 +34,8 @@ public class CockpitHistoryAnalysisController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "page", defaultValue = "1") Integer page,
+            @RequestParam(value = "pageSize", defaultValue = "100") Integer pageSize,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
                 historyAnalysisService.load(
@@ -42,7 +44,9 @@ public class CockpitHistoryAnalysisController {
                         level,
                         industry,
                         startTime,
-                        endTime)));
+                        endTime,
+                        page,
+                        pageSize)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

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

@@ -4,6 +4,7 @@ import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.workorder.CockpitWorkOrderListResponse;
 import com.zksy.cockpit.workorder.CockpitWorkOrderReadResponse;
 import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
 import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineResponse;
@@ -16,6 +17,7 @@ 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;
 
@@ -62,6 +64,38 @@ public class CockpitWorkOrderReadController {
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 
+    /**
+     * 全量工单分页(票 65:报警效率面板的「当前工单列表」)。
+     *
+     * <p>为什么单开一个路径而不是复用 `/equipments/{code}/work-orders`:报警族的工单跨设备,
+     * 面板要的是「当前所有工单」;实测该设备维度能查到 7 张工单,而
+     * `handling-efficiency` 的 linkedWorkOrderCount=0 —— 两者粒度不同,不能互相代替。
+     *
+     * <p>路径挂在 `/api/cockpit/v1/**` 下:免鉴权白名单只按**路径前缀**放行
+     * (`SecurityConfig: antMatchers(GET, "/api/cockpit/v1/**").permitAll()`),
+     * 运营侧 `/api/ops/**` 需要登录态,驾驶舱不能直接用。
+     *
+     * <p>查询参数都可缺省:`page` 默认 1、`pageSize` 默认 10(上限 100)、
+     * `stateCode` 缺省不筛、`startTime`/`endTime`(ISO-8601 带偏移)缺省**不限时间**。
+     * 夹取与时间解析都在 service,控制器不做第二套校验。
+     *
+     * <p>`startTime`/`endTime` 是 2026-10-22 维护者反馈「工单列表没按时间筛」后补的:
+     * 面板按近 7/30/90 天切换时,列表必须跟着收窄(否则上方的状态分布按窗口统计、
+     * 下方列表是全库,读者会以为自相矛盾)。
+     */
+    @GetMapping("/api/cockpit/v1/work-orders")
+    public ResponseEntity<AjaxResult> workOrderList(
+            @RequestParam(value = "page", required = false) Integer page,
+            @RequestParam(value = "pageSize", required = false) Integer pageSize,
+            @RequestParam(value = "stateCode", required = false) Integer stateCode,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitWorkOrderListResponse response = workOrderService.loadAll(
+                resolveRequestId(requestId), page, pageSize, stateCode, startTime, endTime);
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
     @ExceptionHandler(CockpitInvalidRequestException.class)
     public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
         return error(HttpStatus.BAD_REQUEST, workOrderService.invalidRequest(

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

@@ -212,6 +212,15 @@ class CockpitContractIntegrationTest {
         mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
                         .param("eventFamily", "WARNING"))
                 .andExpect(status().isOk());
+        // 票 61:历史分析除小时桶外提供逐事件行分页(与 events/current 同一套页码语义)。
+        mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
+                        .param("eventFamily", "WARNING")
+                        .param("page", "1")
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(10))
+                .andExpect(jsonPath("$.data.events").isArray());
         mockMvc.perform(get("/api/cockpit/v1/events/handling-efficiency")
                         .param("eventFamily", "WARNING"))
                 .andExpect(status().isOk());

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

@@ -0,0 +1,178 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.GasPipePointDeviceRel;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.ManholeDeviceRel;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.mapper.GasPipePointDeviceRelMapper;
+import com.zksy.base.mapper.ManholeDeviceRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.GasPipePointService;
+import com.zksy.base.service.ManholeBaseService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 设备 → 设施关联端点(`/api/cockpit/v1/device-facility-relations`)契约测试。
+ *
+ * <p>锁四件事:
+ * <ol>
+ *   <li>只认真实主键:窨井回 manholeId、燃气管点回所属管段 networkId,解析不到就跳过并记缺;</li>
+ *   <li>设备编码从 equipment_base 精确匹配,查不到时为 null(不拿名字/编码猜);</li>
+ *   <li>真无关联时是空数组(200),查询失败时是 500 错误信封——两者不能混为一谈;</li>
+ *   <li>输出顺序稳定,前端可直接 diff。</li>
+ * </ol>
+ */
+class CockpitDeviceFacilityRelationContractTest {
+    private static final String URL = "/api/cockpit/v1/device-facility-relations";
+
+    private ManholeDeviceRelMapper manholeRelationMapper;
+    private GasPipePointDeviceRelMapper gasPipePointRelationMapper;
+    private ManholeBaseService manholes;
+    private GasPipePointService gasPipePoints;
+    private PipeNetworkBaseService pipeNetworks;
+    private EquipmentBaseService equipments;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        manholeRelationMapper = mock(ManholeDeviceRelMapper.class);
+        gasPipePointRelationMapper = mock(GasPipePointDeviceRelMapper.class);
+        manholes = mock(ManholeBaseService.class);
+        gasPipePoints = mock(GasPipePointService.class);
+        pipeNetworks = mock(PipeNetworkBaseService.class);
+        equipments = mock(EquipmentBaseService.class);
+        CockpitDeviceFacilityRelationService service = new CockpitDeviceFacilityRelationService(
+                manholeRelationMapper, gasPipePointRelationMapper, manholes, gasPipePoints, pipeNetworks, equipments,
+                // 2026-10-22T02:00:00Z == 10:00:00+08:00
+                Clock.fixed(Instant.parse("2026-10-22T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders.standaloneSetup(new CockpitDeviceFacilityRelationController(service)).build();
+    }
+
+    @Test
+    void relationsUseFacilityPrimaryKeysAndSkipUnresolvedRows() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenReturn(Arrays.asList(
+                manholeRelation("M-1", "EQ-1"),
+                manholeRelation("M-LEGACY-DEVICE-ID", "EQ-2"),   // 不是 manhole_base 主键 → 跳过
+                manholeRelation("M-1", "EQ-UNKNOWN")));           // 设备查不到 → deviceCode=null
+        when(gasPipePointRelationMapper.selectList(any())).thenReturn(Arrays.asList(
+                gasRelation(49L, "EQ-1"),
+                gasRelation(50L, "EQ-1"),                         // 管段主键悬空 → 跳过
+                gasRelation(51L, "EQ-1")));                       // 管点不存在 → 跳过
+        when(manholes.listByIds(any())).thenReturn(Collections.singletonList(manhole("M-1")));
+        when(equipments.listByIds(any())).thenReturn(Arrays.asList(equipment("EQ-1", "DEV-1")));
+        when(gasPipePoints.listByIds(any())).thenReturn(Arrays.asList(
+                gasPoint(49L, "NET-1"), gasPoint(50L, "NET-DANGLING")));
+        when(pipeNetworks.listByIds(any())).thenReturn(Collections.singletonList(network("NET-1")));
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.generatedAt").value("2026-10-22T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.relations.length()").value(3))
+                // 窨井用 manholeId,燃气管点用所属管段 networkId
+                .andExpect(jsonPath("$.data.relations[0].deviceCode").value("DEV-1"))
+                .andExpect(jsonPath("$.data.relations[0].facilityCode").value("M-1"))
+                .andExpect(jsonPath("$.data.relations[0].facilityKind").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.relations[0].relationType").value("MANHOLE_DEVICE"))
+                // 设备查不到时如实回 null,不编造编码
+                .andExpect(jsonPath("$.data.relations[1].deviceId").value("EQ-UNKNOWN"))
+                .andExpect(jsonPath("$.data.relations[1].deviceCode").value(nullValue()))
+                .andExpect(jsonPath("$.data.relations[2].facilityCode").value("NET-1"))
+                .andExpect(jsonPath("$.data.relations[2].facilityKind").value("PIPE"))
+                .andExpect(jsonPath("$.data.relations[2].relationType").value("GAS_PIPE_POINT_DEVICE"))
+                .andExpect(jsonPath("$.data.statistics.manholeRelationRows").value(3))
+                .andExpect(jsonPath("$.data.statistics.gasPipePointRelationRows").value(3))
+                .andExpect(jsonPath("$.data.statistics.returnedRelationCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.skippedManholeFacilityMissing").value(1))
+                .andExpect(jsonPath("$.data.statistics.skippedGasPipeNetworkMissing").value(2))
+                .andExpect(jsonPath("$.data.statistics.skippedDeviceIdMissing").value(0))
+                .andExpect(jsonPath("$.data.statistics.deviceCodeUnresolved").value(1));
+    }
+
+    @Test
+    void noRelationsIsEmptyArrayNotError() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(gasPipePointRelationMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                // 空数组 = 确实没有真实关联;前端据此走「同业邻近示意」降级
+                .andExpect(jsonPath("$.data.relations.length()").value(0))
+                .andExpect(jsonPath("$.data.statistics.returnedRelationCount").value(0));
+    }
+
+    @Test
+    void readFailureIsNotReportedAsEmptyCollection() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenThrow(new IllegalStateException("db down"));
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("device facility relation source unavailable"));
+    }
+
+    private static ManholeDeviceRel manholeRelation(String manholeId, String equipmentId) {
+        ManholeDeviceRel relation = new ManholeDeviceRel();
+        relation.setManholeId(manholeId);
+        relation.setEquipmentId(equipmentId);
+        relation.setDelFlag("0");
+        return relation;
+    }
+
+    private static GasPipePointDeviceRel gasRelation(Long pointId, String equipmentId) {
+        GasPipePointDeviceRel relation = new GasPipePointDeviceRel();
+        relation.setPointId(pointId);
+        relation.setEquipmentId(equipmentId);
+        relation.setDelFlag("0");
+        return relation;
+    }
+
+    private static ManholeBase manhole(String manholeId) {
+        ManholeBase manhole = new ManholeBase();
+        manhole.setManholeId(manholeId);
+        return manhole;
+    }
+
+    private static GasPipePoint gasPoint(Long pointId, String networkId) {
+        GasPipePoint point = new GasPipePoint();
+        point.setPointId(pointId);
+        point.setNetworkId(networkId);
+        return point;
+    }
+
+    private static PipeNetworkBase network(String networkId) {
+        PipeNetworkBase network = new PipeNetworkBase();
+        network.setNetworkId(networkId);
+        return network;
+    }
+
+    private static EquipmentBase equipment(String equipmentId, String equipmentCode) {
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId(equipmentId);
+        equipment.setEquipmentCode(equipmentCode);
+        return equipment;
+    }
+}

+ 96 - 6
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java

@@ -88,8 +88,11 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.rows", hasSize(4)))
-                .andExpect(jsonPath("$.data.rows[0].timeBucket").value(BUCKET_23))
-                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(5)))
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(100))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.rows[0].timeBucket").value(BUCKET_23))                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
                 .andExpect(jsonPath("$.data.rows[0].handledCount").value(1))
                 .andExpect(jsonPath("$.data.rows[0].handlingEfficiency.value").value("1.0000"))
                 .andExpect(jsonPath("$.data.rows[0].mapFilter.deviceCodes[0]").value("D-GAS-1"))
@@ -165,7 +168,11 @@ class CockpitHistoryAnalysisControllerTest {
                         .value("NO_ELIGIBLE_WORK_ORDERS"))
                 .andExpect(jsonPath("$.data.rows", hasSize(3)))
                 .andExpect(jsonPath("$.data.rows[0].eventCount").value(0))
-                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.state").value("EMPTY"));
+                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.events", hasSize(0)))
+                .andExpect(jsonPath("$.data.pagination.total").value(0))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(0))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
     }
 
     @Test
@@ -189,7 +196,9 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.filters.level").value(2))
                 .andExpect(jsonPath("$.data.summary.eventCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(0));
+                .andExpect(jsonPath("$.data.summary.handledCount").value(0))
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.pagination.total").value(2));
 
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
@@ -198,7 +207,88 @@ class CockpitHistoryAnalysisControllerTest {
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.summary.eventCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(1));
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(2)));
+    }
+
+    @Test
+    void eventRowsAreOrderedAndPaginated() throws Exception {
+        // 票 61:逐事件行按 eventTime 倒序、同时间按 eventNo 升序(与 events/current 同一口径)。
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "1")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(2))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HW-005"))
+                .andExpect(jsonPath("$.data.events[0].eventTime").value("2026-09-02T01:50:00+08:00"))
+                .andExpect(jsonPath("$.data.events[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.events[0].deviceCode").value("D-WS-2"))
+                .andExpect(jsonPath("$.data.events[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.events[1].eventNo").value("HW-004"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "3")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(1)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HW-001"))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
+
+        // 越界页:事件为空但仍返回成功信封与真实 total(不由当前页反推)。
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "9")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(0)))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
+    }
+
+    @Test
+    void alarmFamilyEventRowsAreIndependentFromWarnings() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HA-002"))
+                .andExpect(jsonPath("$.data.events[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.events[1].eventNo").value("HA-001"))
+                .andExpect(jsonPath("$.data.events[1].levelColor").value("BLUE"))
+                .andExpect(jsonPath("$.data.pagination.total").value(2));
+    }
+
+    @Test
+    void invalidPaginationIsRejectedBeforeAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("page", "0"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("历史分析分页参数不合法"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("pageSize", "1001"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("历史分析分页参数不合法"));
+
+        verifyNoInteractions(adapter);
     }
 
     @Test
@@ -295,7 +385,7 @@ class CockpitHistoryAnalysisControllerTest {
         return new CockpitHistoryAnalysisService(
                 adapter,
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC))
-                .load("direct", family, null, null, start, end);
+                .load("direct", family, null, null, start, end, 1, 100);
     }
 
     private static CockpitHistoryAnalysisSnapshot fixture() {

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

@@ -74,6 +74,8 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.readOnly").value(true))
                 .andExpect(jsonPath("$.data.displayName").value("监测分布"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                // 执行票 62:响应必须回显快照标记(调用方第 2 页起原样回带)。
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
                 .andExpect(jsonPath("$.data.pagination.page").value(1))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
                 .andExpect(jsonPath("$.data.pagination.total").value(9))
@@ -332,6 +334,31 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-500"));
     }
 
+    @Test
+    void snapshotTokenIsEchoedAndStaysStableAcrossPages() throws Exception {
+        // 执行票 62:调用方拿不到 token 就无法跨页对齐。「第 1 页给的 token,
+        // 第 2 页原样回带」必须真的落在同一份快照上,且两页回显同一个 token。
+        String pageOneToken = com.jayway.jsonpath.JsonPath.read(
+                mockMvc.perform(get(ENDPOINT).param("page", "1").param("pageSize", "2"))
+                        .andExpect(status().isOk())
+                        .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                        .andReturn().getResponse().getContentAsString(),
+                "$.data.snapshotAt");
+        mockMvc.perform(get(ENDPOINT).param("page", "2").param("pageSize", "2").param("snapshotAt", pageOneToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.pagination.page").value(2))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(true));
+    }
+
+    @Test
+    void pageBeyondFirstWithoutSnapshotTokenIsRejected() throws Exception {
+        // 反证:第 2 页不带 token 必须 400(否则上面那条「同快照」断言可能是恒真)。
+        mockMvc.perform(get(ENDPOINT).param("page", "2").param("pageSize", "2"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
+    }
+
     @Test
     void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(post(ENDPOINT))

+ 32 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java

@@ -81,12 +81,42 @@ class CockpitMonitoringOverviewControllerTest {
                 .andExpect(jsonPath("$.data.typeDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.typeDistribution[0].onlineCount").value(1))
+                // 执行票 62:运行概况要展示**设备台账**(逐台一行),所以 overview 回出 devices[]。
+                // 与 counts.equipmentCount 同源:行数必须恒等于那个计数(不能有第二个口径)。
+                .andExpect(jsonPath("$.data.devices", hasSize(6)))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].monitoringState").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].deviceType.code").isNotEmpty())
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(6)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
     }
 
+    @Test
+    void deviceLedgerRowsMatchEquipmentCountAndCarryNameAndPoint() throws Exception {
+        // 执行票 62:设备台账 = 逐设备一行,行数**恒等于** counts.equipmentCount;
+        // 每行带设备名(不拿编码冒充)、类型、行业、状态、所属点位、数据时间。
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(6))
+                .andExpect(jsonPath("$.data.devices", hasSize(6)))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].deviceName").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].deviceType.code").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].deviceType.name").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].industry").isNotEmpty())
+                .andExpect(jsonPath("$.data.devices[0].monitoringState").isNotEmpty());
+
+        // 筛选后台账跟着收窄:行数仍等于 equipmentCount(同一个筛选口径)。
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "GAS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(2))
+                .andExpect(jsonPath("$.data.devices", hasSize(2)));
+    }
+
     @Test
     void industryFilterRestrictsOverview() throws Exception {
         when(adapter.load()).thenReturn(fixture());
@@ -327,8 +357,9 @@ class CockpitMonitoringOverviewControllerTest {
 
     private static CockpitMonitoringOverviewSnapshot.SourceDevice device(
             String code, String typeCode, String state, String pointCode) {
+        // 执行票 62:设备台账要显示名称 —— 夹具带上真实形状的名称(`设备-<编码>`)。
         return new CockpitMonitoringOverviewSnapshot.SourceDevice(
-                code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode);
+                code, "设备-" + code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode, null);
     }
 
     private static CockpitMonitoringOverviewSnapshot.SourceDevice deviceAt(

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

@@ -0,0 +1,210 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.workorder.BusinessCockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 全量工单列表端点(票 65):报警效率面板的「当前工单列表」。
+ *
+ * <p>锁四件事,都是**会真的画错界面**的那几件:
+ * <ol>
+ *   <li>不按设备/事件筛——全量口径(查询条件里不得出现 device_id);</li>
+ *   <li>总数取独立 count,不由本页条数反推(票 61 纪律);</li>
+ *   <li>非法 page/pageSize/stateCode 被夹到合法区间并**回显实际生效值**;</li>
+ *   <li>状态字典与每张工单的关联标记照常给出(卡片要按同一份词表上色)。</li>
+ * </ol>
+ */
+class CockpitWorkOrderListContractTest {
+    private static final String BASE = "/api/cockpit/v1";
+    private static final String ORDER_CODE = "WO202610101511303516";
+    private static final String DEVICE_CODE = "21119740";
+    private static final String ALARM_ID = "cd57be18d02f7d3390fb98539b93";
+    private static final String CREATE_TIME = "2026-10-10T15:11:30+08:00";
+
+    private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        for (Class<?> entity : Arrays.asList(EquipmentBase.class, WorkOrder.class, AlarmData.class)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), entity);
+        }
+        workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
+        EquipmentBaseMapper equipmentMapper = mock(EquipmentBaseMapper.class);
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId("equipment-id");
+        equipment.setEquipmentCode(DEVICE_CODE);
+        when(equipmentMapper.selectById("equipment-id")).thenReturn(equipment);
+
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(279L);
+        order.setOrderNo(ORDER_CODE);
+        order.setDeviceId("equipment-id");
+        order.setOrderLevel(2);
+        order.setOrderDesc("监测报警,请现场核实处理");
+        order.setOrderStatus(0);
+        order.setCreateTime(Date.from(Instant.parse("2026-10-10T07:11:30Z")));
+        order.setUpdateTime(Date.from(Instant.parse("2026-10-10T07:11:30Z")));
+
+        when(workOrderService.count(any(LambdaQueryWrapper.class))).thenReturn(7L);
+        when(workOrderService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(order));
+        AlarmData alarm = new AlarmData();
+        alarm.setId(ALARM_ID);
+        alarm.setWorkOrderId(279L);
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm));
+
+        BusinessCockpitWorkOrderReadAdapter adapter = new BusinessCockpitWorkOrderReadAdapter(
+                workOrderService, mock(EarlyWarningService.class), mock(AlarmWarningRelMapper.class),
+                alarmDataService, equipmentMapper);
+        CockpitWorkOrderReadService service = new CockpitWorkOrderReadService(
+                adapter, Clock.fixed(Instant.parse("2026-10-10T07:11:30Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders.standaloneSetup(new CockpitWorkOrderReadController(service)).build();
+    }
+
+    @Test
+    void listIsGlobalNotScopedToDeviceOrEvent() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.state").value("READY"))
+                .andExpect(jsonPath("$.data.workOrders[0].workOrderCode").value(ORDER_CODE))
+                // 总数是 count 的结果(7),不是本页 1 条
+                .andExpect(jsonPath("$.data.pagination.total").value(7))
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(10))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
+                // 状态字典必须给(卡片按同一词表上色)
+                .andExpect(jsonPath("$.data.stateDictionary[0].code").value(0));
+    }
+
+    @Test
+    void pageAndSizeAreClampedAndEchoedBack() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("page", "0").param("pageSize", "9999"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(100));
+
+        // 夹取后才下到适配器:offset 必须由生效值算出,不能让非法值进 SQL
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce())
+                .list(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    void invalidStateCodeFallsBackToNoFilter() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("stateCode", "99"))
+                .andExpect(status().isOk());
+        // 越界状态码当成"不筛",仍然返回数据(而不是空列表假读数)
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce()).count(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    void validStateCodeIsApplied() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("stateCode", "3"))
+                .andExpect(status().isOk());
+        verify(workOrderService).count(any(LambdaQueryWrapper.class));
+    }
+
+    /**
+     * 时间窗口必须真的下到 SQL(维护者 2026-10-22 反馈「工单列表没按时间筛」)。
+     *
+     * <p>这里抓的是 `count(wrapper)` 收到的条件:必须同时含 `create_time >=` 与
+     * `create_time <=`,并且两端的值就是请求传进来的那两个时刻 —— 只断言「请求 200」
+     * 抓不到「参数收下了但没用」这种缺陷。
+     */
+    @Test
+    @SuppressWarnings("unchecked")
+    void timeWindowIsAppliedToCreateTime() throws Exception {
+        String start = "2026-09-21T00:00:00+08:00";
+        String end = "2026-10-21T23:59:59+08:00";
+        mockMvc.perform(get(BASE + "/work-orders").param("startTime", start).param("endTime", end))
+                .andExpect(status().isOk());
+
+        ArgumentCaptor<LambdaQueryWrapper<WorkOrder>> queries = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce()).count(queries.capture());
+        LambdaQueryWrapper<WorkOrder> query = queries.getValue();
+        String sql = query.getSqlSegment();
+        assertTrue(sql.contains("createTime"), "时间窗必须落在 createTime(create_time 列)上,实际:" + sql);
+        assertTrue(sql.contains(">="), "缺少下界 createTime >=,实际:" + sql);
+        assertTrue(sql.contains("<="), "缺少上界 createTime <=,实际:" + sql);
+        assertEquals(2, query.getParamNameValuePairs().size(),
+                "下界与上界都必须带上,实际:" + query.getParamNameValuePairs());
+        // 参数值就是请求里那两个时刻(+08:00 换算成 UTC 后对应这两个 Instant)
+        java.util.List<Object> values = new ArrayList<>(query.getParamNameValuePairs().values());
+        assertTrue(values.contains(java.util.Date.from(java.time.OffsetDateTime.parse(start).toInstant())),
+                "下界值与请求不一致:" + values);
+        assertTrue(values.contains(java.util.Date.from(java.time.OffsetDateTime.parse(end).toInstant())),
+                "上界值与请求不一致:" + values);
+    }
+
+    /** 不带时间参数时绝不加时间条件(缺省=不限,既有机能不受影响)。 */
+    @Test
+    @SuppressWarnings("unchecked")
+    void absentWindowAddsNoTimeCondition() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders")).andExpect(status().isOk());
+        ArgumentCaptor<LambdaQueryWrapper<WorkOrder>> queries = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce()).count(queries.capture());
+        String sql = queries.getValue().getSqlSegment();
+        // 注意: 本身也含 ,所以不能按字段名判 ——
+        // 要看有没有**比较条件**(>= / <=)。
+        assertFalse(sql.contains(">=") || sql.contains("<="),
+                "没传时间参数却加了时间比较条件:" + sql);
+    }
+
+    /** 时间格式不合法 → 400(不静默忽略,否则「以为筛了其实是全量」最难发现)。 */
+    @Test
+    void invalidWindowIsRejected() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("startTime", "2026-09-21"))
+                .andExpect(status().isBadRequest());
+    }
+
+    /** 起点晚于终点 → 400(空窗口是入参错误,不是「没有工单」)。 */
+    @Test
+    void reversedWindowIsRejected() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders")
+                        .param("startTime", "2026-10-21T00:00:00+08:00")
+                        .param("endTime", "2026-09-21T00:00:00+08:00"))
+                .andExpect(status().isBadRequest());
+    }
+}

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

@@ -405,26 +405,54 @@ public class BusinessCockpitEventAdapter {
         }
     }
 
+    /**
+     * 票 61:历史分析除小时桶聚合外还要返回**可分页的逐事件行**,所以这里把
+     * 展示字段(名称/状态/设备名/位置/量值…)一并带进快照——与
+     * `currentWarning/currentAlarm` 取同一份事实,两族事件行形状因此同构。
+     */
     private List<CockpitHistoryAnalysisSnapshot.SourceWarning> historyWarnings(Facts facts) {
-        return facts.warnings.stream()
-                .map(item -> new CockpitHistoryAnalysisSnapshot.SourceWarning(
-                        trimToNull(item.warning.getWarningNo()),
-                        level(item.warning.getWarningLevel()),
-                        offsetTime(item.warning.getPublishTime()),
-                        typeCode(item.warning.getDeviceCode(), facts),
-                        trimToNull(item.warning.getDeviceCode())))
-                .collect(Collectors.toList());
+        List<CockpitHistoryAnalysisSnapshot.SourceWarning> result = new ArrayList<>();
+        for (WarningFact item : facts.warnings) {
+            EarlyWarning warning = item.warning;
+            PointFact point = facts.pointsByDevice.get(normalize(warning.getDeviceCode()));
+            result.add(new CockpitHistoryAnalysisSnapshot.SourceWarning(
+                    trimToNull(warning.getWarningNo()),
+                    level(warning.getWarningLevel()),
+                    offsetTime(warning.getPublishTime()),
+                    typeCode(warning.getDeviceCode(), facts),
+                    trimToNull(warning.getDeviceCode()),
+                    trimToNull(warning.getStatus()),
+                    firstNonBlank(warning.getDeviceName(), deviceName(warning.getDeviceCode(), facts)),
+                    firstNonBlank(warning.getLocation(), point == null ? null : point.location),
+                    offsetTime(warning.getUpdateTime())));
+        }
+        return result;
     }
 
     private List<CockpitHistoryAnalysisSnapshot.SourceAlarm> historyAlarms(Facts facts) {
-        return facts.alarms.stream()
-                .map(item -> new CockpitHistoryAnalysisSnapshot.SourceAlarm(
-                        trimToNull(item.alarm.getId()),
-                        item.alarm.getAlarmLevel(),
-                        offsetTime(item.alarm.getCreateTime()),
-                        typeCode(item.alarm.getDeviceCode(), facts),
-                        trimToNull(item.alarm.getDeviceCode())))
-                .collect(Collectors.toList());
+        List<CockpitHistoryAnalysisSnapshot.SourceAlarm> result = new ArrayList<>();
+        for (AlarmFact item : facts.alarms) {
+            AlarmData alarm = item.alarm;
+            EquipmentWarningType metadata = warningType(alarm, facts);
+            PointFact point = facts.pointsByDevice.get(normalize(alarm.getDeviceCode()));
+            EquipmentBase device = facts.devicesByCode.get(normalize(alarm.getDeviceCode()));
+            result.add(new CockpitHistoryAnalysisSnapshot.SourceAlarm(
+                    trimToNull(alarm.getId()),
+                    alarm.getAlarmLevel(),
+                    offsetTime(alarm.getCreateTime()),
+                    typeCode(alarm.getDeviceCode(), facts),
+                    trimToNull(alarm.getDeviceCode()),
+                    firstNonBlank(warningTypeName(metadata), alarm.getWarningCode()),
+                    alarmStatus(alarm.getAlarmStatus()),
+                    firstNonBlank(alarm.getDeviceName(), device == null ? null : device.getEquipmentName()),
+                    point == null ? (device == null ? null : device.getEquipmentLocation()) : point.location,
+                    offsetTime(alarm.getUpdateTime()),
+                    alarm.getActualValue(),
+                    warningTypeUnit(metadata),
+                    alarm.getMinValue(),
+                    alarm.getMaxValue()));
+        }
+        return result;
     }
 
     private List<CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink> historyWorkOrders(Facts facts) {

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

@@ -1,11 +1,25 @@
 package com.zksy.cockpit.event;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 
 import java.math.BigDecimal;
+import java.util.Collections;
 import java.util.List;
 
+/**
+ * 预警处置 / 历史分析响应。
+ *
+ * <p>两块内容并存:
+ * <ul>
+ *   <li>{@code rows[]}:小时桶聚合(趋势与行级处置率口径,历史既有契约,未改动);</li>
+ *   <li>{@code events[]} + {@code pagination}:**逐事件行**(票 61 新增,形状与
+ *       {@code /events/current} 的 {@code EventItem} 同构),按 eventTime 倒序、
+ *       同时间按 eventNo 升序,分页由 {@code page/pageSize} 控制。</li>
+ * </ul>
+ * 逐事件行与聚合桶取自同一份窗口内事件集合,两条口径可互相复算。
+ */
 public class CockpitHistoryAnalysisResponse {
     private final String requestId;
     private final String apiVersion;
@@ -17,6 +31,8 @@ public class CockpitHistoryAnalysisResponse {
     private final String updatedAt;
     private final Filters filters;
     private final Summary summary;
+    private final Pagination pagination;
+    private final List<EventItem> events;
     private final List<Row> rows;
 
     public CockpitHistoryAnalysisResponse(
@@ -30,6 +46,8 @@ public class CockpitHistoryAnalysisResponse {
             String updatedAt,
             Filters filters,
             Summary summary,
+            Pagination pagination,
+            List<EventItem> events,
             List<Row> rows) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
@@ -41,6 +59,8 @@ public class CockpitHistoryAnalysisResponse {
         this.updatedAt = updatedAt;
         this.filters = filters;
         this.summary = summary;
+        this.pagination = pagination;
+        this.events = events == null ? Collections.emptyList() : events;
         this.rows = rows;
     }
 
@@ -54,6 +74,8 @@ public class CockpitHistoryAnalysisResponse {
     public String getUpdatedAt() { return updatedAt; }
     public Filters getFilters() { return filters; }
     public Summary getSummary() { return summary; }
+    public Pagination getPagination() { return pagination; }
+    public List<EventItem> getEvents() { return events; }
     public List<Row> getRows() { return rows; }
     public CockpitFreshnessStatus getFreshness() {
         return rows == null || rows.isEmpty()
@@ -131,6 +153,45 @@ public class CockpitHistoryAnalysisResponse {
         public String getReason() { return reason; }
     }
 
+    /** 逐事件行:直接复用当前事件端点的 {@code EventItem} 形状(同一份大屏读模型)。 */
+    public static class EventItem extends CockpitCurrentEventsResponse.EventItem {
+        public EventItem(
+                String eventNo,
+                String eventName,
+                String eventType,
+                Integer level,
+                String levelColor,
+                String status,
+                String eventTime,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                String deviceCode,
+                String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
+                String location,
+                String industry,
+                BigDecimal longitude,
+                BigDecimal latitude,
+                CockpitCoordinateValidation coordinateValidation,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue,
+                CockpitCurrentEventsResponse.WorkOrderSummary workOrderSummary) {
+            super(eventNo, eventName, eventType, level, levelColor, status, eventTime, updatedTime,
+                    value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
+                    location, industry, longitude, latitude, coordinateValidation,
+                    thresholdMinValue, thresholdMaxValue, workOrderSummary);
+        }
+    }
+
+    /** 分页元数据:与 {@code /events/current} 的 {@code Pagination} 同形状(同一套页码语义)。 */
+    public static class Pagination extends CockpitCurrentEventsResponse.Pagination {
+        public Pagination(int page, int pageSize, int total, int totalPages, boolean hasMore) {
+            super(page, pageSize, total, totalPages, hasMore);
+        }
+    }
+
     public static class Row {
         private final String timeBucket;
         private final int eventCount;
@@ -138,8 +199,12 @@ public class CockpitHistoryAnalysisResponse {
         private final HandlingEfficiency handlingEfficiency;
         private final RowMapFilter mapFilter;
 
-        public Row(String timeBucket, int eventCount, int handledCount,
-                HandlingEfficiency handlingEfficiency, RowMapFilter mapFilter) {
+        public Row(
+                String timeBucket,
+                int eventCount,
+                int handledCount,
+                HandlingEfficiency handlingEfficiency,
+                RowMapFilter mapFilter) {
             this.timeBucket = timeBucket;
             this.eventCount = eventCount;
             this.handledCount = handledCount;

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

@@ -33,6 +33,11 @@ import java.util.Set;
  * isolated, and resolves the handled disposal scope through the authoritative
  * work-order dictionary (ADR-0010): an event counts as handled only when its
  * associated work order stateCode equals 5 (已结案).
+ *
+ * <p>票 61(前端统一分页批次三):响应同时携带**小时桶聚合**(趋势,口径不变)
+ * 与**逐事件行**({@code events[]},形状与 {@code /events/current} 的 EventItem 同构,
+ * 按 eventTime 倒序、同时间按 eventNo 升序),逐事件行由 {@code page/pageSize} 分页。
+ * 两条口径取自同一份窗口内事件集合,可互相复算。
  */
 @Service
 public class CockpitHistoryAnalysisService {
@@ -42,6 +47,8 @@ public class CockpitHistoryAnalysisService {
     private static final String WARNING = "WARNING";
     private static final String ALARM = "ALARM";
     private static final int HANDLED_STATE_CODE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
+    private static final int DEFAULT_PAGE_SIZE = 100;
+    private static final int MAX_PAGE_SIZE = 1000;
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
     private static final DateTimeFormatter TIMESTAMP =
@@ -64,18 +71,23 @@ public class CockpitHistoryAnalysisService {
             String level,
             String industry,
             String startTime,
-            String endTime) {
+            String endTime,
+            Integer page,
+            Integer pageSize) {
         String family = resolveFamily(eventFamily);
         Integer resolvedLevel = resolveLevel(level);
         String selectedIndustry = resolveIndustry(industry);
         TimeWindow window = resolveWindow(startTime, endTime);
+        int safePage = page == null ? 1 : page;
+        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
+        validatePagination(safePage, safePageSize);
         if (state == null) {
             CockpitHistoryAnalysisSnapshot loaded = adapter.load();
             state = loaded == null
                     ? CockpitHistoryAnalysisSnapshot.empty(businessTime())
                     : loaded;
         }
-        return build(requestId, family, resolvedLevel, selectedIndustry, window);
+        return build(requestId, family, resolvedLevel, selectedIndustry, window, safePage, safePageSize);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -99,12 +111,14 @@ public class CockpitHistoryAnalysisService {
             String family,
             Integer level,
             String industry,
-            TimeWindow window) {
+            TimeWindow window,
+            int page,
+            int pageSize) {
         Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types = types(state);
         Map<String, Integer> handledLinks = handledLinks(state.getWorkOrders());
-        List<EventFact> facts = WARNING.equals(family)
-                ? warningFacts(state, types, level, industry)
-                : alarmFacts(state, types, level, industry);
+        List<EventRow> eventRows = WARNING.equals(family)
+                ? warningRows(state, types, level, industry)
+                : alarmRows(state, types, level, industry);
 
         ZonedDateTime first = window.start
                 .atZoneSameInstant(BUSINESS_ZONE)
@@ -122,11 +136,9 @@ public class CockpitHistoryAnalysisService {
 
         int totalEvents = 0;
         int totalHandled = 0;
-        for (EventFact fact : facts) {
-            if (fact.eventTime == null) {
-                continue;
-            }
-            ZonedDateTime bucket = fact.eventTime
+        List<EventRow> inWindow = new ArrayList<>();
+        for (EventRow row : eventRows) {
+            ZonedDateTime bucket = row.eventTime
                     .atZoneSameInstant(BUSINESS_ZONE)
                     .truncatedTo(ChronoUnit.HOURS);
             String key = TIMESTAMP.format(bucket);
@@ -134,15 +146,16 @@ public class CockpitHistoryAnalysisService {
             if (target == null) {
                 continue;
             }
+            inWindow.add(row);
             totalEvents++;
             target.eventCount++;
-            Integer stateCode = fact.eventNo == null ? null : handledLinks.get(fact.eventNo.trim());
+            Integer stateCode = row.eventNo == null ? null : handledLinks.get(row.eventNo.trim());
             if (HANDLED_STATE_CODE == effectiveStateCode(stateCode)) {
                 target.handledCount++;
                 totalHandled++;
             }
-            if (!isBlank(fact.deviceCode)) {
-                target.deviceCodes.add(fact.deviceCode.trim());
+            if (!isBlank(row.deviceCode)) {
+                target.deviceCodes.add(row.deviceCode.trim());
             }
         }
 
@@ -158,6 +171,17 @@ public class CockpitHistoryAnalysisService {
                             family, sorted(bucket.deviceCodes))));
         }
 
+        // 票 61:逐事件行分页(与 hours buckets 同一份窗口内事件集合)。
+        inWindow.sort(eventOrdering());
+        int total = inWindow.size();
+        int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
+        int fromIndex = Math.min((page - 1) * pageSize, total);
+        int toIndex = Math.min(fromIndex + pageSize, total);
+        List<CockpitHistoryAnalysisResponse.EventItem> events = new ArrayList<>();
+        for (EventRow row : inWindow.subList(fromIndex, toIndex)) {
+            events.add(row.toItem());
+        }
+
         String updatedAt = isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt();
         // 汇总处置率=范围内全部工单(状态 0–6)口径(决策票 06),与行级事件桶分开;
         // 行级桶仍是事件时间桶,其 efficiency 保留事件口径。
@@ -180,17 +204,21 @@ public class CockpitHistoryAnalysisService {
                         // 保持事件口径,与行级 handledCount 同维(决策 q7:只改汇总处置率)。
                         totalHandled,
                         scopeEfficiency(scopeRate.handled, scopeRate.eligible)),
+                new CockpitHistoryAnalysisResponse.Pagination(
+                        page, pageSize, total, totalPages, toIndex < total),
+                events,
                 rows);
     }
 
-    private List<EventFact> warningFacts(
+    /** 预警族逐事件行(字段取自历史快照;时间/等级/设备/位置与当前事件端点同源)。 */
+    private List<EventRow> warningRows(
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
             String industry) {
-        List<EventFact> facts = new ArrayList<>();
+        List<EventRow> rows = new ArrayList<>();
         if (snapshot.getWarnings() == null) {
-            return facts;
+            return rows;
         }
         for (CockpitHistoryAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
             if (warning == null
@@ -198,22 +226,38 @@ public class CockpitHistoryAnalysisService {
                     || (level != null && !level.equals(warning.getLevel()))) {
                 continue;
             }
-            facts.add(new EventFact(
+            OffsetDateTime eventTime = parseEventTime(warning.getEventTime());
+            if (eventTime == null) {
+                continue;
+            }
+            rows.add(new EventRow(
                     warning.getWarningNo(),
-                    parseEventTime(warning.getEventTime()),
-                    warning.getDeviceCode()));
-        }
-        return facts;
+                    warning.getWarningNo(),
+                    warning.getTypeCode(),
+                    warning.getLevel(),
+                    warning.getStatus(),
+                    eventTime,
+                    warning.getUpdatedTime(),
+                    null,
+                    null,
+                    warning.getDeviceCode(),
+                    warning.getDeviceName(),
+                    warning.getLocation(),
+                    industryOf(types, warning.getTypeCode()),
+                    null,
+                    null));
+        }
+        return rows;
     }
 
-    private List<EventFact> alarmFacts(
+    private List<EventRow> alarmRows(
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
             String industry) {
-        List<EventFact> facts = new ArrayList<>();
+        List<EventRow> rows = new ArrayList<>();
         if (snapshot.getAlarms() == null) {
-            return facts;
+            return rows;
         }
         for (CockpitHistoryAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
             if (alarm == null
@@ -221,12 +265,28 @@ public class CockpitHistoryAnalysisService {
                     || (level != null && !level.equals(alarm.getLevel()))) {
                 continue;
             }
-            facts.add(new EventFact(
+            OffsetDateTime eventTime = parseEventTime(alarm.getEventTime());
+            if (eventTime == null) {
+                continue;
+            }
+            rows.add(new EventRow(
                     alarm.getAlarmId(),
-                    parseEventTime(alarm.getEventTime()),
-                    alarm.getDeviceCode()));
-        }
-        return facts;
+                    isBlank(alarm.getEventName()) ? alarm.getAlarmId() : alarm.getEventName(),
+                    alarm.getTypeCode(),
+                    alarm.getLevel(),
+                    alarm.getStatus(),
+                    eventTime,
+                    alarm.getUpdatedTime(),
+                    alarm.getValue(),
+                    alarm.getUnit(),
+                    alarm.getDeviceCode(),
+                    alarm.getDeviceName(),
+                    alarm.getLocation(),
+                    industryOf(types, alarm.getTypeCode()),
+                    alarm.getMinValue(),
+                    alarm.getMaxValue()));
+        }
+        return rows;
     }
 
     private static boolean inScope(
@@ -244,6 +304,15 @@ public class CockpitHistoryAnalysisService {
         return industry == null || industry.equals(type.getIndustry());
     }
 
+    /** 事件行的行业=其类型所属行业(未登记类型回 null,不猜)。 */
+    private static String industryOf(
+            Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
+            String typeCode) {
+        CockpitHistoryAnalysisSnapshot.SourceType type =
+                isBlank(typeCode) ? null : types.get(typeCode.trim());
+        return type == null ? null : type.getIndustry();
+    }
+
     /**
      * 范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
      * 按 {@code create_time} 界定窗口、按行业(经类型)收敛,与事件是否回联无关。
@@ -391,6 +460,12 @@ public class CockpitHistoryAnalysisService {
         return resolved;
     }
 
+    private static void validatePagination(int page, int pageSize) {
+        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new CockpitInvalidRequestException("历史分析分页参数不合法");
+        }
+    }
+
     private TimeWindow resolveWindow(String startTime, String endTime) {
         boolean hasStart = !isBlank(startTime);
         boolean hasEnd = !isBlank(endTime);
@@ -456,6 +531,109 @@ public class CockpitHistoryAnalysisService {
         return value == null || value.trim().isEmpty();
     }
 
+    /** 逐事件行(领域内形状):聚合与逐事件清单共用一份事实。 */
+    private static class EventRow {
+        private final String eventNo;
+        private final String eventName;
+        private final String eventType;
+        private final Integer level;
+        private final String status;
+        private final OffsetDateTime eventTime;
+        private final String updatedTime;
+        private final BigDecimal value;
+        private final String unit;
+        private final String deviceCode;
+        private final String deviceName;
+        private final String location;
+        private final String industry;
+        private final BigDecimal minValue;
+        private final BigDecimal maxValue;
+
+        private EventRow(
+                String eventNo,
+                String eventName,
+                String eventType,
+                Integer level,
+                String status,
+                OffsetDateTime eventTime,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                String deviceCode,
+                String deviceName,
+                String location,
+                String industry,
+                BigDecimal minValue,
+                BigDecimal maxValue) {
+            this.eventNo = eventNo;
+            this.eventName = eventName;
+            this.eventType = eventType;
+            this.level = level;
+            this.status = status;
+            this.eventTime = eventTime;
+            this.updatedTime = updatedTime;
+            this.value = value;
+            this.unit = unit;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.industry = industry;
+            this.minValue = minValue;
+            this.maxValue = maxValue;
+        }
+
+        private String getEventNo() {
+            return eventNo;
+        }
+
+        /** 逐事件行 → 与 `/events/current` 同构的 EventItem(缺的字段留 null,不编造)。 */
+        private CockpitHistoryAnalysisResponse.EventItem toItem() {
+            return new CockpitHistoryAnalysisResponse.EventItem(
+                    eventNo,
+                    isBlank(eventName) ? eventNo : eventName,
+                    eventType,
+                    level,
+                    CockpitEventLevelColor.resolve(level),
+                    status,
+                    timestamp(eventTime),
+                    updatedTime,
+                    value,
+                    unit,
+                    deviceCode,
+                    deviceName,
+                    // 历史快照不携带监测点与坐标(逐事件坐标只在 events/current 提供):
+                    // 保留字段为 null,前端按既有缺值守卫处理。
+                    null,
+                    null,
+                    location,
+                    industry,
+                    null,
+                    null,
+                    null,
+                    minValue,
+                    maxValue,
+                    null);
+        }
+    }
+
+    /** 逐事件排序:eventTime 倒序(缺时间排最后),同时间按 eventNo 升序(与当前事件端点同一口径)。 */
+    private static Comparator<EventRow> eventOrdering() {
+        Comparator<EventRow> byTime = (left, right) -> {
+            if (left.eventTime == null && right.eventTime == null) {
+                return 0;
+            }
+            if (left.eventTime == null) {
+                return 1;
+            }
+            if (right.eventTime == null) {
+                return -1;
+            }
+            return right.eventTime.compareTo(left.eventTime);
+        };
+        return byTime.thenComparing(
+                EventRow::getEventNo, Comparator.nullsLast(String::compareTo));
+    }
+
     private static class TimeWindow {
         private final OffsetDateTime start;
         private final OffsetDateTime end;
@@ -466,18 +644,6 @@ public class CockpitHistoryAnalysisService {
         }
     }
 
-    private static class EventFact {
-        private final String eventNo;
-        private final OffsetDateTime eventTime;
-        private final String deviceCode;
-
-        private EventFact(String eventNo, OffsetDateTime eventTime, String deviceCode) {
-            this.eventNo = eventNo;
-            this.eventTime = eventTime;
-            this.deviceCode = deviceCode;
-        }
-    }
-
     private static class Bucket {
         private final Set<String> deviceCodes = new LinkedHashSet<>();
         private int eventCount;

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

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.event;
 
+import java.math.BigDecimal;
 import java.util.Collections;
 import java.util.List;
 
@@ -9,6 +10,11 @@ import java.util.List;
  * <p>Delivers both event families separately plus optional work-order links used
  * to resolve the handled disposal scope. A null workOrders list is treated as no
  * associated work orders (handled count zero).
+ *
+ * <p>票 61(前端统一分页批次三):快照行除聚合所需的最小字段外,还携带逐事件行
+ * 展示字段(名称/状态/设备名/位置/量值…),供历史分析返回**可分页的逐事件清单**。
+ * 早期 5 参构造保留(只填聚合字段),新 9/14 参构造由业务适配器提供完整字段——
+ * 这样既有测试与空适配器无需改动。
  */
 public class CockpitHistoryAnalysisSnapshot {
     private final String updatedAt;
@@ -140,18 +146,41 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String eventTime;
         private final String typeCode;
         private final String deviceCode;
+        private final String status;
+        private final String deviceName;
+        private final String location;
+        private final String updatedTime;
 
+        /** 聚合最小字段(既有测试/空适配器用)。 */
         public SourceWarning(
                 String warningNo,
                 Integer level,
                 String eventTime,
                 String typeCode,
                 String deviceCode) {
+            this(warningNo, level, eventTime, typeCode, deviceCode, null, null, null, null);
+        }
+
+        /** 票 61:逐事件行展示字段(业务适配器用)。 */
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String status,
+                String deviceName,
+                String location,
+                String updatedTime) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
+            this.status = status;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.updatedTime = updatedTime;
         }
 
         public String getWarningNo() {
@@ -173,6 +202,22 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getDeviceCode() {
             return deviceCode;
         }
+
+        public String getStatus() {
+            return status;
+        }
+
+        public String getDeviceName() {
+            return deviceName;
+        }
+
+        public String getLocation() {
+            return location;
+        }
+
+        public String getUpdatedTime() {
+            return updatedTime;
+        }
     }
 
     public static class SourceAlarm {
@@ -181,18 +226,57 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String eventTime;
         private final String typeCode;
         private final String deviceCode;
-
+        private final String eventName;
+        private final String status;
+        private final String deviceName;
+        private final String location;
+        private final String updatedTime;
+        private final BigDecimal value;
+        private final String unit;
+        private final BigDecimal minValue;
+        private final BigDecimal maxValue;
+
+        /** 聚合最小字段(既有测试/空适配器用)。 */
         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);
+        }
+
+        /** 票 61:逐事件行展示字段(业务适配器用)。 */
+        public SourceAlarm(
+                String alarmId,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String eventName,
+                String status,
+                String deviceName,
+                String location,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                BigDecimal minValue,
+                BigDecimal maxValue) {
             this.alarmId = alarmId;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
+            this.eventName = eventName;
+            this.status = status;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.updatedTime = updatedTime;
+            this.value = value;
+            this.unit = unit;
+            this.minValue = minValue;
+            this.maxValue = maxValue;
         }
 
         public String getAlarmId() {
@@ -214,6 +298,42 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getDeviceCode() {
             return deviceCode;
         }
+
+        public String getEventName() {
+            return eventName;
+        }
+
+        public String getStatus() {
+            return status;
+        }
+
+        public String getDeviceName() {
+            return deviceName;
+        }
+
+        public String getLocation() {
+            return location;
+        }
+
+        public String getUpdatedTime() {
+            return updatedTime;
+        }
+
+        public BigDecimal getValue() {
+            return value;
+        }
+
+        public String getUnit() {
+            return unit;
+        }
+
+        public BigDecimal getMinValue() {
+            return minValue;
+        }
+
+        public BigDecimal getMaxValue() {
+            return maxValue;
+        }
     }
 
     public static class SourceWorkOrderLink {

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

@@ -123,6 +123,8 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             }
             devices.add(new CockpitMonitoringOverviewSnapshot.SourceDevice(
                     deviceCode,
+                    // 执行票 62:设备台账要显示名称(缺名如实为 null,不拿编码冒充)。
+                    isBlank(device.getEquipmentName()) ? null : device.getEquipmentName().trim(),
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     monitoringState(status),
                     status == null || status.getStatusUpdateTime() == null

+ 9 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -15,6 +15,12 @@ public class CockpitMonitoringDistributionResponse {
     private final String displayName;
     private final String timezone;
     private final Range range;
+    /**
+     * 本次响应所依据的**快照标记**(执行票 62 补):与 {@code map-snapshot} 的
+     * {@code snapshotAt} 同形同义。调用方第 2 页起必须原样回带该值,
+     * 否则后端无法保证同一批分页视图落在同一份快照上(`validatePagination` 会 400)。
+     */
+    private final String snapshotAt;
     private final String updatedAt;
     /**
      * 数据时间=记录创建时间({@code create_time})跨台账对象取 max;
@@ -39,6 +45,7 @@ public class CockpitMonitoringDistributionResponse {
             String displayName,
             String timezone,
             Range range,
+            String snapshotAt,
             String updatedAt,
             String createdAt,
             Freshness freshness,
@@ -57,6 +64,7 @@ public class CockpitMonitoringDistributionResponse {
         this.displayName = displayName;
         this.timezone = timezone;
         this.range = range;
+        this.snapshotAt = snapshotAt;
         this.updatedAt = updatedAt;
         this.createdAt = createdAt;
         this.freshness = freshness;
@@ -77,6 +85,7 @@ public class CockpitMonitoringDistributionResponse {
     public String getDisplayName() { return displayName; }
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
+    public String getSnapshotAt() { return snapshotAt; }
     public String getUpdatedAt() { return updatedAt; }
     public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java

@@ -429,6 +429,9 @@ public class CockpitMonitoringDistributionService {
                 "监测分布",
                 "Asia/Shanghai",
                 new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
+                // 执行票 62:回显本次响应依据的快照标记(与 map-snapshot 同形),
+                // 调用方第 2 页起原样回带——在此之前响应不给 token,跨页分页无从对齐。
+                snapshot.getSnapshotAt(),
                 updatedAt,
                 createdAt,
                 new CockpitMonitoringDistributionResponse.Freshness(

+ 59 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java

@@ -22,6 +22,12 @@ public class CockpitMonitoringOverviewResponse {
     private final Status status;
     private final OnlineRate onlineRate;
     private final List<TypeDistribution> typeDistribution;
+    /**
+     * 逐设备台账(执行票 62 追加需求):与 {@code counts.equipmentCount} 同源同口径
+     * (筛选后的智能设备全集,逐台一行),供运行概况的「设备台账」清单直接渲染。
+     * 顺序=后端遍历顺序(稳定),前端不重排。
+     */
+    private final List<Device> devices;
     private final MapFilter mapFilter;
     private final Excluded excluded;
 
@@ -39,6 +45,7 @@ public class CockpitMonitoringOverviewResponse {
             Status status,
             OnlineRate onlineRate,
             List<TypeDistribution> typeDistribution,
+            List<Device> devices,
             MapFilter mapFilter,
             Excluded excluded) {
         this.requestId = requestId;
@@ -54,6 +61,7 @@ public class CockpitMonitoringOverviewResponse {
         this.status = status;
         this.onlineRate = onlineRate;
         this.typeDistribution = typeDistribution;
+        this.devices = devices;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
     }
@@ -71,6 +79,7 @@ public class CockpitMonitoringOverviewResponse {
     public Status getStatus() { return status; }
     public OnlineRate getOnlineRate() { return onlineRate; }
     public List<TypeDistribution> getTypeDistribution() { return typeDistribution; }
+    public List<Device> getDevices() { return devices; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
 
@@ -164,6 +173,56 @@ public class CockpitMonitoringOverviewResponse {
         public String getReason() { return reason; }
     }
 
+    /**
+     * 逐设备台账行(执行票 62 追加需求)。
+     *
+     * <p>字段只取「运行概况的设备台账」这一屏真正要显示的:设备编码/名称、类型、行业、
+     * 监测状态、所属监测点、采样时间、数据时间(记录创建时间)。**不在这里加设备坐标**
+     * ——那是 `map-snapshot` 的职责;也不加阈值 ——那是 `monitoring/situation` 的异常口径。
+     * 缺值一律 {@code null},不填 0、不填空串冒充。
+     */
+    public static class Device {
+        private final String deviceCode;
+        private final String deviceName;
+        private final DeviceType deviceType;
+        private final String industry;
+        /** 设备监测状态(后端 `MONITORING_STATE` 五态之一);缺失为 {@code null}。 */
+        private final String monitoringState;
+        /** 所属监测点编码(未关联设备为 {@code null})。 */
+        private final String monitoringPointCode;
+        private final String sampledAt;
+        /** 数据时间=记录创建时间({@code equipment_base.create_time})。 */
+        private final String createdAt;
+
+        public Device(
+                String deviceCode,
+                String deviceName,
+                DeviceType deviceType,
+                String industry,
+                String monitoringState,
+                String monitoringPointCode,
+                String sampledAt,
+                String createdAt) {
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+            this.deviceType = deviceType;
+            this.industry = industry;
+            this.monitoringState = monitoringState;
+            this.monitoringPointCode = monitoringPointCode;
+            this.sampledAt = sampledAt;
+            this.createdAt = createdAt;
+        }
+
+        public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
+        public DeviceType getDeviceType() { return deviceType; }
+        public String getIndustry() { return industry; }
+        public String getMonitoringState() { return monitoringState; }
+        public String getMonitoringPointCode() { return monitoringPointCode; }
+        public String getSampledAt() { return sampledAt; }
+        public String getCreatedAt() { return createdAt; }
+    }
+
     public static class TypeDistribution {
         private final DeviceType deviceType;
         private final String industry;

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java

@@ -216,6 +216,34 @@ public class CockpitMonitoringOverviewService {
         List<String> pointCodes = new ArrayList<>(selectedPointCodes);
         List<String> deviceCodes = new ArrayList<>(selectedDeviceCodes);
 
+        // 执行票 62 追加需求:运行概况要展示**设备台账**(逐台一行),不是只有聚合计数。
+        // 逐设备行与 `counts.equipmentCount` 同源(同一次遍历产出的 `selectedDevices`),
+        // 所以两处读数结构性一致——台账行数就是 equipmentCount,不会出现第二个口径。
+        List<CockpitMonitoringOverviewResponse.Device> devices = new ArrayList<>();
+        for (DeviceRuntime runtime : state.devices.values()) {
+            String typeCode = runtime.typeCode;
+            CockpitMonitoringOverviewSnapshot.SourceType type = isBlank(typeCode) ? null : types.get(typeCode);
+            // 与上面的筛选分支**同一套判据**:只把真正计入本响应的设备放进台账。
+            if (type == null
+                    || !SMART_DEVICE.equals(type.getObjectKind())
+                    || type.getIndustry() == null
+                    || !INDUSTRIES.contains(type.getIndustry())
+                    || (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry()))
+                    || (!isBlank(deviceType) && !deviceType.trim().equals(typeCode))) {
+                continue;
+            }
+            CockpitMonitoringOverviewSnapshot.SourceDevice source = snapshotDevice(snapshot, runtime.deviceCode);
+            devices.add(new CockpitMonitoringOverviewResponse.Device(
+                    runtime.deviceCode,
+                    source == null ? null : source.getDeviceName(),
+                    new CockpitMonitoringOverviewResponse.DeviceType(type.getCode(), type.getName()),
+                    type.getIndustry(),
+                    runtime.monitoringState,
+                    isBlank(runtime.pointCode) ? null : runtime.pointCode,
+                    runtime.sampledAt,
+                    source == null ? null : source.getCreatedAt()));
+        }
+
         return new CockpitMonitoringOverviewResponse(
                 requestId,
                 "v1",
@@ -237,6 +265,7 @@ public class CockpitMonitoringOverviewService {
                 new CockpitMonitoringOverviewResponse.OnlineRate(
                         rate, online, denominator, rateState, rateReason),
                 typeDistribution,
+                devices,
                 new CockpitMonitoringOverviewResponse.MapFilter(
                         selectedIndustry,
                         isBlank(deviceType) ? null : deviceType.trim(),

+ 17 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java

@@ -45,6 +45,8 @@ public class CockpitMonitoringOverviewSnapshot {
 
     public static class SourceDevice {
         private final String deviceCode;
+        /** 设备名称({@code equipment_base.equipment_name});缺值为 {@code null}(不拿编码冒充)。 */
+        private final String deviceName;
         private final String typeCode;
         private final String monitoringState;
         private final String sampledAt;
@@ -58,7 +60,7 @@ public class CockpitMonitoringOverviewSnapshot {
                 String monitoringState,
                 String sampledAt,
                 String pointCode) {
-            this(deviceCode, typeCode, monitoringState, sampledAt, pointCode, null);
+            this(deviceCode, null, typeCode, monitoringState, sampledAt, pointCode, null);
         }
 
         public SourceDevice(
@@ -68,7 +70,20 @@ public class CockpitMonitoringOverviewSnapshot {
                 String sampledAt,
                 String pointCode,
                 String createdAt) {
+            this(deviceCode, null, typeCode, monitoringState, sampledAt, pointCode, createdAt);
+        }
+
+        /** 执行票 62 追加:设备台账要显示名称,只有编码不可读。 */
+        public SourceDevice(
+                String deviceCode,
+                String deviceName,
+                String typeCode,
+                String monitoringState,
+                String sampledAt,
+                String pointCode,
+                String createdAt) {
             this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
             this.typeCode = typeCode;
             this.monitoringState = monitoringState;
             this.sampledAt = sampledAt;
@@ -77,6 +92,7 @@ public class CockpitMonitoringOverviewSnapshot {
         }
 
         public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
         public String getTypeCode() { return typeCode; }
         public String getMonitoringState() { return monitoringState; }
         public String getSampledAt() { return sampledAt; }

+ 130 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationResponse.java

@@ -0,0 +1,130 @@
+package com.zksy.cockpit.relation;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.List;
+
+/**
+ * 设备 → 设施关联只读契约(免鉴权 cockpit v1:`GET /api/cockpit/v1/device-facility-relations`)。
+ *
+ * <p>数据来源(只读,不写库、不推导、不猜关联):
+ * <ul>
+ *   <li>窨井:{@code app_user.manhole_device_rel}(del_flag=0)→ {@code app_user.manhole_base.manhole_id};</li>
+ *   <li>燃气管点:{@code app_user.gas_pipe_point_device_rel}(del_flag=0)→ {@code app_user.gas_pipe_point.network_id}
+ *       → {@code app_user.pipe_network_base.network_id}(返回**管段**主键,因为前端挂载的是管线);</li>
+ *   <li>{@code deviceCode} 取 {@code app_user.equipment_base.equipment_code};查不到时为 null,只回退到
+ *       {@code deviceId}(关联表里的 equipment_id 原值),绝不用名字或编码去猜。</li>
+ * </ul>
+ *
+ * <p>降级语义(前端据此判定「真实关联」是否成立):
+ * <ul>
+ *   <li>关联行的设施主键在设施表里不存在/已删除 → 该条不返回,计入统计 {@code skipped*};</li>
+ *   <li>一条都取不到 → {@code relations} 为空数组(不是错误),前端按「同业邻近示意」降级;</li>
+ *   <li>查询本身失败 → HTTP 500 + {@code AjaxResult} 错误信封,不返回空数组冒充「无关联」。</li>
+ * </ul>
+ */
+public class CockpitDeviceFacilityRelationResponse {
+
+    /** 生成时间(Asia/Shanghai,ISO-8601 带偏移)。 */
+    private final String generatedAt;
+    /** 本端点恒为只读。 */
+    private final boolean readOnly;
+    /** 真实关联行;无关联时为空数组。 */
+    private final List<Relation> relations;
+    /** 口径统计:返回了几条、因何跳过几条。 */
+    private final Statistics statistics;
+
+    public CockpitDeviceFacilityRelationResponse(String generatedAt, boolean readOnly, List<Relation> relations,
+            Statistics statistics) {
+        this.generatedAt = generatedAt;
+        this.readOnly = readOnly;
+        this.relations = relations;
+        this.statistics = statistics;
+    }
+
+    public String getGeneratedAt() { return generatedAt; }
+
+    public boolean isReadOnly() { return readOnly; }
+
+    public List<Relation> getRelations() { return relations; }
+
+    public Statistics getStatistics() { return statistics; }
+
+    /** 一条设备 → 设施真实关联。 */
+    public static class Relation {
+        /** 设备主键(关联表 equipment_id 原值,始终非空)。 */
+        private final String deviceId;
+        /** 设备编码(equipment_base.equipment_code);查不到时为 null,但字段始终回显(契约稳定)。 */
+        @JsonInclude(JsonInclude.Include.ALWAYS)
+        private final String deviceCode;
+        /** 设施主键:窨井为 manholeId,燃气管点为所属管段 networkId。 */
+        private final String facilityCode;
+        /** 设施种类:MANHOLE / PIPE。 */
+        private final String facilityKind;
+        /** 关联类型:MANHOLE_DEVICE / GAS_PIPE_POINT_DEVICE。 */
+        private final String relationType;
+
+        public Relation(String deviceId, String deviceCode, String facilityCode, String facilityKind,
+                String relationType) {
+            this.deviceId = deviceId;
+            this.deviceCode = deviceCode;
+            this.facilityCode = facilityCode;
+            this.facilityKind = facilityKind;
+            this.relationType = relationType;
+        }
+
+        public String getDeviceId() { return deviceId; }
+
+        public String getDeviceCode() { return deviceCode; }
+
+        public String getFacilityCode() { return facilityCode; }
+
+        public String getFacilityKind() { return facilityKind; }
+
+        public String getRelationType() { return relationType; }
+    }
+
+    /** 口径统计:让前端和排查者能区分「没有关联」和「关联丢了」。 */
+    public static class Statistics {
+        /** manhole_device_rel 中 del_flag=0 的行数。 */
+        private final int manholeRelationRows;
+        /** gas_pipe_point_device_rel 中 del_flag=0 的行数。 */
+        private final int gasPipePointRelationRows;
+        /** 实际返回的关联条数。 */
+        private final int returnedRelationCount;
+        /** 因窨井主键在 manhole_base 里查不到(含已删除)而跳过的条数。 */
+        private final int skippedManholeFacilityMissing;
+        /** 因管点所属管段 networkId 在 pipe_network_base 里查不到(含已删除)而跳过的条数。 */
+        private final int skippedGasPipeNetworkMissing;
+        /** 关联行设备主键为空、无法定位设备而跳过的条数。 */
+        private final int skippedDeviceIdMissing;
+        /** 设备主键在 equipment_base 里查不到、deviceCode 只能为 null 的条数。 */
+        private final int deviceCodeUnresolved;
+
+        public Statistics(int manholeRelationRows, int gasPipePointRelationRows, int returnedRelationCount,
+                int skippedManholeFacilityMissing, int skippedGasPipeNetworkMissing, int skippedDeviceIdMissing,
+                int deviceCodeUnresolved) {
+            this.manholeRelationRows = manholeRelationRows;
+            this.gasPipePointRelationRows = gasPipePointRelationRows;
+            this.returnedRelationCount = returnedRelationCount;
+            this.skippedManholeFacilityMissing = skippedManholeFacilityMissing;
+            this.skippedGasPipeNetworkMissing = skippedGasPipeNetworkMissing;
+            this.skippedDeviceIdMissing = skippedDeviceIdMissing;
+            this.deviceCodeUnresolved = deviceCodeUnresolved;
+        }
+
+        public int getManholeRelationRows() { return manholeRelationRows; }
+
+        public int getGasPipePointRelationRows() { return gasPipePointRelationRows; }
+
+        public int getReturnedRelationCount() { return returnedRelationCount; }
+
+        public int getSkippedManholeFacilityMissing() { return skippedManholeFacilityMissing; }
+
+        public int getSkippedGasPipeNetworkMissing() { return skippedGasPipeNetworkMissing; }
+
+        public int getSkippedDeviceIdMissing() { return skippedDeviceIdMissing; }
+
+        public int getDeviceCodeUnresolved() { return deviceCodeUnresolved; }
+    }
+}

+ 249 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationService.java

@@ -0,0 +1,249 @@
+package com.zksy.cockpit.relation;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.GasPipePointDeviceRel;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.ManholeDeviceRel;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.mapper.GasPipePointDeviceRelMapper;
+import com.zksy.base.mapper.ManholeDeviceRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.GasPipePointService;
+import com.zksy.base.service.ManholeBaseService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationResponse.Relation;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationResponse.Statistics;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 设备 → 设施关联只读服务。
+ *
+ * <p><b>数据来源</b>(只读查询,不写库、不推导):
+ * <ol>
+ *   <li>窨井:{@code manhole_device_rel.manhole_id} → {@code manhole_base.manhole_id},设施主键即 manholeId;</li>
+ *   <li>燃气管点:{@code gas_pipe_point_device_rel.point_id} → {@code gas_pipe_point.network_id}
+ *       → {@code pipe_network_base.network_id},返回**管段**主键(前端挂的是管线);</li>
+ *   <li>设备编码:{@code equipment_base.equipment_code}(按 rel.equipment_id 精确匹配主键)。</li>
+ * </ol>
+ *
+ * <p><b>不编造</b>:只用上表主键做等值解析;解析不到就跳过该条并在 {@link Statistics} 里记缺,
+ * 既不按名称/编码猜设施,也不把查不到设备的行填个假 deviceCode。
+ *
+ * <p><b>降级语义</b>:一条都解析不到 → {@code relations} 为空数组(正常响应);查询失败 → 抛
+ * {@link CockpitReadException},由控制器转 500,不把失败伪装成「无关联」。
+ */
+@Service
+public class CockpitDeviceFacilityRelationService {
+    private static final Logger LOG = LoggerFactory.getLogger(CockpitDeviceFacilityRelationService.class);
+
+    private static final String SCHEMA_TIME_ZONE = "Asia/Shanghai";
+    private static final String FACILITY_KIND_MANHOLE = "MANHOLE";
+    private static final String FACILITY_KIND_PIPE = "PIPE";
+    private static final String RELATION_TYPE_MANHOLE_DEVICE = "MANHOLE_DEVICE";
+    private static final String RELATION_TYPE_GAS_PIPE_POINT_DEVICE = "GAS_PIPE_POINT_DEVICE";
+
+    private final ManholeDeviceRelMapper manholeRelationMapper;
+    private final GasPipePointDeviceRelMapper gasPipePointRelationMapper;
+    private final ManholeBaseService manholes;
+    private final GasPipePointService gasPipePoints;
+    private final PipeNetworkBaseService pipeNetworks;
+    private final EquipmentBaseService equipments;
+    private final Clock clock;
+
+    public CockpitDeviceFacilityRelationService(ManholeDeviceRelMapper manholeRelationMapper,
+            GasPipePointDeviceRelMapper gasPipePointRelationMapper, ManholeBaseService manholes,
+            GasPipePointService gasPipePoints, PipeNetworkBaseService pipeNetworks, EquipmentBaseService equipments,
+            Clock clock) {
+        this.manholeRelationMapper = manholeRelationMapper;
+        this.gasPipePointRelationMapper = gasPipePointRelationMapper;
+        this.manholes = manholes;
+        this.gasPipePoints = gasPipePoints;
+        this.pipeNetworks = pipeNetworks;
+        this.equipments = equipments;
+        this.clock = clock;
+    }
+
+    public CockpitDeviceFacilityRelationResponse load() {
+        try {
+            // del_flag 显式写进条件:即使全局逻辑删除配置漂移,也只会读到未删除的关联行。
+            List<ManholeDeviceRel> manholeRelations = manholeRelationMapper.selectList(
+                    new LambdaQueryWrapper<ManholeDeviceRel>().eq(ManholeDeviceRel::getDelFlag, "0"));
+            List<GasPipePointDeviceRel> gasRelations = gasPipePointRelationMapper.selectList(
+                    new LambdaQueryWrapper<GasPipePointDeviceRel>().eq(GasPipePointDeviceRel::getDelFlag, "0"));
+
+            Map<String, String> deviceCodeById = loadDeviceCodes(manholeRelations, gasRelations);
+            Set<String> existingManholeIds = loadExistingManholeIds(manholeRelations);
+            Map<Long, String> networkIdByPointId = loadNetworkIdByPointId(gasRelations);
+            Set<String> existingNetworkIds = loadExistingNetworkIds(networkIdByPointId.values());
+
+            List<Relation> relations = new ArrayList<>();
+            int skippedManholeMissing = 0;
+            int skippedGasNetworkMissing = 0;
+            int skippedDeviceMissing = 0;
+            int deviceCodeUnresolved = 0;
+
+            for (ManholeDeviceRel relation : manholeRelations) {
+                String deviceId = trimToNull(relation.getEquipmentId());
+                String manholeId = trimToNull(relation.getManholeId());
+                if (manholeId == null || !existingManholeIds.contains(manholeId)) {
+                    skippedManholeMissing++;
+                    continue;
+                }
+                if (deviceId == null) {
+                    skippedDeviceMissing++;
+                    continue;
+                }
+                String deviceCode = trimToNull(deviceCodeById.get(deviceId));
+                if (deviceCode == null) {
+                    deviceCodeUnresolved++;
+                }
+                relations.add(new Relation(deviceId, deviceCode, manholeId, FACILITY_KIND_MANHOLE,
+                        RELATION_TYPE_MANHOLE_DEVICE));
+            }
+
+            for (GasPipePointDeviceRel relation : gasRelations) {
+                String deviceId = trimToNull(relation.getEquipmentId());
+                String networkId = trimToNull(networkIdByPointId.get(relation.getPointId()));
+                if (networkId == null || !existingNetworkIds.contains(networkId)) {
+                    skippedGasNetworkMissing++;
+                    continue;
+                }
+                if (deviceId == null) {
+                    skippedDeviceMissing++;
+                    continue;
+                }
+                String deviceCode = trimToNull(deviceCodeById.get(deviceId));
+                if (deviceCode == null) {
+                    deviceCodeUnresolved++;
+                }
+                relations.add(new Relation(deviceId, deviceCode, networkId, FACILITY_KIND_PIPE,
+                        RELATION_TYPE_GAS_PIPE_POINT_DEVICE));
+            }
+
+            // 顺序固定,便于前端 diff / 排查时不因 SQL 返回顺序抖动。
+            relations.sort(Comparator.comparing(Relation::getFacilityKind)
+                    .thenComparing(Relation::getFacilityCode)
+                    .thenComparing(Relation::getDeviceId));
+
+            Statistics statistics = new Statistics(manholeRelations.size(), gasRelations.size(), relations.size(),
+                    skippedManholeMissing, skippedGasNetworkMissing, skippedDeviceMissing, deviceCodeUnresolved);
+            String generatedAt = DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                    .format(clock.instant().atZone(ZoneId.of(SCHEMA_TIME_ZONE)));
+            return new CockpitDeviceFacilityRelationResponse(generatedAt, true, relations, statistics);
+        } catch (RuntimeException exception) {
+            // 失败不能退化成空数组:空数组在前端代表「确实没有真实关联」。
+            LOG.error("Device facility relation source read failed", exception);
+            throw new CockpitReadException("device facility relation source unavailable");
+        }
+    }
+
+    /** 设备主键 → 设备编码;只做等值匹配,不按名称/编码猜。 */
+    private Map<String, String> loadDeviceCodes(List<ManholeDeviceRel> manholeRelations,
+            List<GasPipePointDeviceRel> gasRelations) {
+        Set<String> deviceIds = new LinkedHashSet<>();
+        for (ManholeDeviceRel relation : manholeRelations) {
+            addIfPresent(deviceIds, relation.getEquipmentId());
+        }
+        for (GasPipePointDeviceRel relation : gasRelations) {
+            addIfPresent(deviceIds, relation.getEquipmentId());
+        }
+        Map<String, String> deviceCodeById = new HashMap<>();
+        if (deviceIds.isEmpty()) {
+            return deviceCodeById;
+        }
+        for (EquipmentBase equipment : equipments.listByIds(deviceIds)) {
+            if (equipment != null && equipment.getEquipmentId() != null) {
+                deviceCodeById.put(equipment.getEquipmentId(), equipment.getEquipmentCode());
+            }
+        }
+        return deviceCodeById;
+    }
+
+    /** 只认 manhole_base 里真实存在(未删除)的窨井主键。 */
+    private Set<String> loadExistingManholeIds(List<ManholeDeviceRel> manholeRelations) {
+        Set<String> manholeIds = new LinkedHashSet<>();
+        for (ManholeDeviceRel relation : manholeRelations) {
+            addIfPresent(manholeIds, relation.getManholeId());
+        }
+        Set<String> existing = new HashSet<>();
+        if (manholeIds.isEmpty()) {
+            return existing;
+        }
+        for (ManholeBase manhole : manholes.listByIds(manholeIds)) {
+            if (manhole != null && manhole.getManholeId() != null) {
+                existing.add(manhole.getManholeId());
+            }
+        }
+        return existing;
+    }
+
+    /** 管点主键 → 所属管段 networkId(管点表自身字段,原样读出)。 */
+    private Map<Long, String> loadNetworkIdByPointId(List<GasPipePointDeviceRel> gasRelations) {
+        Set<Long> pointIds = new LinkedHashSet<>();
+        for (GasPipePointDeviceRel relation : gasRelations) {
+            if (relation.getPointId() != null) {
+                pointIds.add(relation.getPointId());
+            }
+        }
+        Map<Long, String> networkIdByPointId = new HashMap<>();
+        if (pointIds.isEmpty()) {
+            return networkIdByPointId;
+        }
+        for (GasPipePoint point : gasPipePoints.listByIds(pointIds)) {
+            if (point != null && point.getPointId() != null) {
+                networkIdByPointId.put(point.getPointId(), point.getNetworkId());
+            }
+        }
+        return networkIdByPointId;
+    }
+
+    /** 只认 pipe_network_base 里真实存在(未删除)的管段主键。 */
+    private Set<String> loadExistingNetworkIds(Iterable<String> networkIds) {
+        Set<String> candidates = new LinkedHashSet<>();
+        for (String networkId : networkIds) {
+            addIfPresent(candidates, networkId);
+        }
+        Set<String> existing = new HashSet<>();
+        if (candidates.isEmpty()) {
+            return existing;
+        }
+        for (PipeNetworkBase network : pipeNetworks.listByIds(candidates)) {
+            if (network != null && network.getNetworkId() != null) {
+                existing.add(network.getNetworkId());
+            }
+        }
+        return existing;
+    }
+
+    private static void addIfPresent(Set<String> target, String value) {
+        String trimmed = trimToNull(value);
+        if (trimmed != null) {
+            target.add(trimmed);
+        }
+    }
+
+    private static String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+}

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

@@ -76,6 +76,62 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                 .collect(Collectors.toList());
     }
 
+    /**
+     * 全量工单分页(票 65:报警效率面板的「当前工单列表」)。
+     *
+     * <p>与设备维度同一条读取路径,只是**不加 device_id 约束**:报警族的工单在库里
+     * 本就跨设备存在(实测该端点 24 条报警事件在 handling-efficiency 里
+     * linkedWorkOrderCount=0,但设备维度能查到 7 张工单),所以「当前工单」只能是全量口径。
+     *
+     * <p>三个刻意的取舍:
+     * <ul>
+     *   <li>{@code total} 走 {@code count(wrapper)} 独立查询,不由本页条数反推(票 61 纪律);</li>
+     *   <li>排序与设备维度一致(创建时间倒序)——两个入口返回同一张表时顺序必须相同;</li>
+     *   <li>关联解析仍走 {@link #toAlarmSummary}:全量列表里「是否关联报警」逐张如实标,
+     *       不因为换了个查询入口就一律标 true/false。</li>
+     * </ul>
+     *
+     * <p><b>时间窗口按 {@code create_time} 过滤</b>(2026-10-22 维护者反馈:列表没按时间筛)。
+     * 用 {@code create_time} 而不是 {@code update_time} 是刻意的 —— 面板上方「工单状态分布」
+     * 的分母来自 `CockpitHandlingEfficiencyService.scopeWorkOrders(...)`,那里同样是
+     * 「按 create_time 落在窗口内」。两处必须是**同一批工单**,否则会出现
+     * 「分布说 0 张、列表列出 7 张」的自相矛盾。
+     */
+    @Override
+    public CockpitWorkOrderListSnapshot loadAll(
+            Integer stateCode,
+            Date startTime,
+            Date endTime,
+            int limit,
+            int offset) {
+        LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
+        if (stateCode != null) {
+            wrapper.eq(WorkOrder::getOrderStatus, stateCode);
+        }
+        if (startTime != null) {
+            wrapper.ge(WorkOrder::getCreateTime, startTime);
+        }
+        if (endTime != null) {
+            wrapper.le(WorkOrder::getCreateTime, endTime);
+        }
+        long total = workOrderService.count(wrapper);
+        List<WorkOrder> orders = total <= offset
+                ? Collections.emptyList()
+                : workOrderService.list(wrapper
+                        .orderByDesc(WorkOrder::getCreateTime)
+                        .last("LIMIT " + limit + " OFFSET " + offset));
+        // 空页时不再打一次 association 查询:alarmsByOrderId 对空集合本来就早退,但显式一点更省一次调用栈。
+        List<CockpitWorkOrderSummary> summaries = orders.isEmpty()
+                ? Collections.emptyList()
+                : toAlarmSummaries(orders);
+        return new CockpitWorkOrderListSnapshot(
+                summaries,
+                (int) total,
+                limit <= 0 ? 1 : (offset / limit) + 1,
+                limit,
+                stateCode);
+    }
+
     /**
      * 按对外报警标识解析报警:先按 {@code alarm_no}(真实单号),再退回主键 {@code id}。
      *

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

@@ -0,0 +1,69 @@
+package com.zksy.cockpit.workorder;
+
+import java.util.List;
+
+/**
+ * 全量工单列表响应(票 65:报警效率面板的「当前工单列表」)。
+ *
+ * <p>与按设备/按事件的 {@link CockpitWorkOrderReadResponse} 保持同一套外形约定
+ * ({@code requestId/apiVersion/readOnly/state/stateReason/stateDictionary/workOrders}),
+ * 只把「设备/事件」两个维度字段换成分页四件套 —— 前端两处可共用同一份转换与卡片渲染。
+ */
+public class CockpitWorkOrderListResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String state;
+    private final String stateReason;
+    private final List<CockpitWorkOrderState> stateDictionary;
+    private final List<CockpitWorkOrderSummary> workOrders;
+    private final Pagination pagination;
+
+    public CockpitWorkOrderListResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String state,
+            String stateReason,
+            List<CockpitWorkOrderState> stateDictionary,
+            List<CockpitWorkOrderSummary> workOrders,
+            Pagination pagination) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.state = state;
+        this.stateReason = stateReason;
+        this.stateDictionary = stateDictionary;
+        this.workOrders = workOrders;
+        this.pagination = pagination;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getState() { return state; }
+    public String getStateReason() { return stateReason; }
+    public List<CockpitWorkOrderState> getStateDictionary() { return stateDictionary; }
+    public List<CockpitWorkOrderSummary> getWorkOrders() { return workOrders; }
+    public Pagination getPagination() { return pagination; }
+
+    /** 分页回显(与其它驾驶舱端点同名字段:page/pageSize/total/totalPages,前端读同一套)。 */
+    public static class Pagination {
+        private final int page;
+        private final int pageSize;
+        private final int total;
+        private final int totalPages;
+
+        public Pagination(int page, int pageSize, int total) {
+            this.page = page;
+            this.pageSize = pageSize;
+            this.total = total;
+            this.totalPages = pageSize <= 0 ? 1 : Math.max(1, (total + pageSize - 1) / pageSize);
+        }
+
+        public int getPage() { return page; }
+        public int getPageSize() { return pageSize; }
+        public int getTotal() { return total; }
+        public int getTotalPages() { return totalPages; }
+    }
+}

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

@@ -0,0 +1,37 @@
+package com.zksy.cockpit.workorder;
+
+import java.util.List;
+
+/**
+ * 全量工单分页快照(票 65)。
+ *
+ * <p>与 {@link CockpitWorkOrderReadSnapshot} 的区别:那份回答「某设备/某事件下有哪些工单」,
+ * 本份回答「全库当前有哪些工单、共多少张」——所以多一个 {@code total}:前端要翻页,
+ * 就必须知道总数(不由当前页反推,票 61 的同一纪律)。
+ */
+public class CockpitWorkOrderListSnapshot {
+    private final List<CockpitWorkOrderSummary> workOrders;
+    private final int total;
+    private final int page;
+    private final int pageSize;
+    private final Integer stateCode;
+
+    public CockpitWorkOrderListSnapshot(
+            List<CockpitWorkOrderSummary> workOrders,
+            int total,
+            int page,
+            int pageSize,
+            Integer stateCode) {
+        this.workOrders = workOrders;
+        this.total = total;
+        this.page = page;
+        this.pageSize = pageSize;
+        this.stateCode = stateCode;
+    }
+
+    public List<CockpitWorkOrderSummary> getWorkOrders() { return workOrders; }
+    public int getTotal() { return total; }
+    public int getPage() { return page; }
+    public int getPageSize() { return pageSize; }
+    public Integer getStateCode() { return stateCode; }
+}

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

@@ -1,9 +1,36 @@
 package com.zksy.cockpit.workorder;
 
+import java.util.Date;
+
 public interface CockpitWorkOrderReadAdapter {
     CockpitWorkOrderReadSnapshot loadByDevice(String deviceCode);
 
     CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode);
 
     CockpitWorkOrderTimelineSnapshot loadTimeline(String workOrderCode);
-}
+
+    /**
+     * 全量工单分页(票 65:报警效率面板要列「当前工单列表」,不按设备也不按事件筛)。
+     *
+     * <p>与设备维度共用同一条读取路径(运营侧 {@code WorkOrderService})与同一份
+     * {@link CockpitWorkOrderSummary} 映射,区别只在不加 {@code device_id} 约束。
+     * 排序口径与设备维度一致(创建时间倒序),前端才能稳定翻页。
+     *
+     * <p><b>时间窗口按 {@code create_time} 过滤</b>,与
+     * `CockpitHandlingEfficiencyService.scopeWorkOrders(...)` 同一口径 ——
+     * 面板上的「工单状态分布」与这份列表必须统计**同一批工单**,
+     * 否则会出现「分布说 0 张、列表却列出 7 张」的自相矛盾(2026-10-22 维护者反馈)。
+     *
+     * @param stateCode 工单状态码 0–7;{@code null} 表示不筛
+     * @param startTime 窗口起点(含);{@code null} 表示不限下界
+     * @param endTime   窗口终点(含);{@code null} 表示不限上界
+     * @param limit     本页条数(调用方已夹到合法区间)
+     * @param offset    偏移量,{@code (page-1)*limit}
+     */
+    CockpitWorkOrderListSnapshot loadAll(
+            Integer stateCode,
+            Date startTime,
+            Date endTime,
+            int limit,
+            int offset);
+}

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

@@ -8,16 +8,25 @@ import com.zksy.cockpit.foundation.CockpitReadException;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
+import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
 
 @Service
 public class CockpitWorkOrderReadService {
     private static final List<String> VALID_EVENT_SOURCES = Arrays.asList("ALARM", "WARNING");
 
+    /** 全量工单列表的默认页大小(与驾驶舱其它列表端点一致)。 */
+    private static final int DEFAULT_PAGE_SIZE = 10;
+
+    /** 单次最多取多少张:防一个 pageSize 把全库拉下来。 */
+    private static final int MAX_PAGE_SIZE = 100;
+
     private final CockpitWorkOrderReadAdapter adapter;
     private final Clock clock;
 
@@ -64,6 +73,72 @@ public class CockpitWorkOrderReadService {
         return error(requestId, CockpitErrorCategory.INVALID_REQUEST, "工单查询参数不合法");
     }
 
+    /**
+     * 全量工单分页(票 65:报警效率面板的「当前工单列表」,不按设备也不按事件)。
+     *
+     * <p>翻页参数在这里**夹到合法区间**,不信任入参直接下到 SQL 的 LIMIT/OFFSET:
+     * 非法页码轻则返回错页、重则合成语法错误的 SQL。夹取后回显实际生效值,
+     * 前端据此纠偏(与票 61「越界页码夹回」同一条纪律,只是这次发生在服务端入口)。
+     *
+     * @param page      页码 1 起(null/越界 → 1)
+     * @param pageSize  每页条数(null/越界 → 10;上限 100,避免一次拉全库)
+     * @param stateCode 工单状态码 0–7(null 或越界 → 不筛)
+     * @param startTime 窗口起点 ISO-8601(含);空 → 不限下界。**不合法一律 400**,
+     *                  不静默忽略 —— 静默忽略会让「以为筛了时间、其实全量」这种错看不出
+     * @param endTime   窗口终点 ISO-8601(含);空 → 不限上界
+     */
+    public CockpitWorkOrderListResponse loadAll(
+            String requestId,
+            Integer page,
+            Integer pageSize,
+            Integer stateCode,
+            String startTime,
+            String endTime) {
+        int safePage = page == null || page < 1 ? 1 : page;
+        int safeSize = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
+        Integer safeState = stateCode != null && stateCode >= 0 && stateCode <= 7 ? stateCode : null;
+        Date windowStart = parseWindowEdge(startTime, "startTime");
+        Date windowEnd = parseWindowEdge(endTime, "endTime");
+        if (windowStart != null && windowEnd != null && windowStart.after(windowEnd)) {
+            throw new CockpitInvalidRequestException("startTime must not be after endTime");
+        }
+        CockpitWorkOrderListSnapshot snapshot = adapter.loadAll(
+                safeState, windowStart, windowEnd, safeSize, (safePage - 1) * safeSize);
+        if (snapshot == null) {
+            throw new CockpitReadException("work order list unavailable");
+        }
+        List<CockpitWorkOrderSummary> workOrders = snapshot.getWorkOrders() == null
+                ? new ArrayList<>() : snapshot.getWorkOrders();
+        boolean empty = workOrders.isEmpty();
+        return new CockpitWorkOrderListResponse(
+                requestId,
+                "v1",
+                true,
+                empty ? "EMPTY" : "READY",
+                empty ? "NO_WORK_ORDERS" : "READY",
+                CockpitWorkOrderStateDictionary.states(),
+                workOrders,
+                new CockpitWorkOrderListResponse.Pagination(safePage, safeSize, snapshot.getTotal()));
+    }
+
+    /**
+     * 时间窗口端点 → {@link Date}(ISO-8601,必须带时区偏移)。
+     *
+     * <p>空串/null 视为「不限」;**写了但不合法一律抛 INVALID_REQUEST**。
+     * 为什么不容错成「忽略这个参数」:那样前端传错格式时,界面会显示全量数据却
+     * 看起来像已按窗口筛选 —— 一种最难发现的假绿。
+     */
+    private static Date parseWindowEdge(String text, String name) {
+        if (text == null || text.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Date.from(OffsetDateTime.parse(text.trim()).toInstant());
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException(name + " must be ISO-8601 with offset");
+        }
+    }
+
     public CockpitErrorPayload notFound(String requestId) {
         return error(requestId, CockpitErrorCategory.NOT_FOUND, "工单或关联对象不存在");
     }

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

@@ -18,4 +18,14 @@ public class EmptyCockpitWorkOrderReadAdapter implements CockpitWorkOrderReadAda
     public CockpitWorkOrderTimelineSnapshot loadTimeline(String workOrderCode) {
         return null;
     }
+
+    @Override
+    public CockpitWorkOrderListSnapshot loadAll(
+            Integer stateCode,
+            java.util.Date startTime,
+            java.util.Date endTime,
+            int limit,
+            int offset) {
+        return null;
+    }
 }