Explorar o código

feat(cockpit): 新增全量工单分页只读端点,支持时间窗(票 65)

报警效率面板要列「当前工单列表」,但既有驾驶舱工单端点只有按设备/按事件/按时间线
三个入口,`handling-efficiency` 又只给状态计数、不返回工单实体。补一个全量分页入口。

- GET /api/cockpit/v1/work-orders(挂在免认证前缀下,运营侧 /api/ops/** 需登录态);
- page/pageSize 在服务端夹到合法区间并回显,total 走独立 count 不按本页反推;
- startTime/endTime 可选,按 create_time 过滤,与
  CockpitHandlingEfficiencyService.scopeWorkOrders 同一口径——列表与面板上的
  「工单状态分布」必须统计同一批工单;缺省不限时间;
- 时间格式非法、起点晚于终点一律 400(不静默忽略,避免「以为筛了其实是全量」);
- 关联标记沿用 toAlarmSummary 逐张如实标注,不因换入口就一律 true/false。

测试:CockpitWorkOrderListContractTest 共 8 条(全量口径、分页夹取、
时间窗下到 SQL 且两端实参正确、缺省不加时间条件、非法格式 400、起点晚于终点 400、
状态码缺省/生效);工单相关合计 35/35,驾驶舱全量 433/433。
Kazerin hai 10 horas
pai
achega
51dfba402f

+ 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(

+ 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());
+    }
+}

+ 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;
+    }
 }