ソースを参照

feat(cockpit): 单设备「报警全部由未结束工单承接」时判工单处置

维护者 2026-10-17 裁定(票 52):设备存在报警、且该设备的**全部未处理报警**
(alarm_data.alarm_status=0)都已由**未结束工单**承接(work_order.order_status ∈
{0,1,2,3,4,6})时,单设备状态判 WORK_ORDER(工单处置),高于报警/预警;
否则维持现行序 alarm → warning → workOrderInProgress → online?NORMAL:OFFLINE。

口径要点(不猜、不粉饰):
- 「全部」是逐条成立,不是「至少一条」;已结案(5)/已取消(7) 不算承接。
- 查不到未处理报警行(或压根没有 alarm_data 行)→ 不推定已承接,维持 ALARM:
  「查不到」不等于「已承接」,不把数据缺口渲染成处置中。
- 读失败同样保守降级为 ALARM。
- 只作用于**单设备**判定;点位聚合 resolve() 仍取 min(priority),
  枚举 priority 与前端词表不变(票面非目标,前端零耦合)。

实现:事实由 BusinessCockpitDeviceFactResolver 计算(新增 alarm_data 依赖),
随 CockpitMonitoringPointDeviceSnapshot / 分布快照透传——分布适配器与
effectiveDevice 重建、点位详情归一化三处都必须携带,否则结论在服务层被抹掉
(变异 M6/M7 专门钉这两个接缝)。判定单源仍是 deviceStatus()。

验证:Cockpit*/BusinessCockpit* 512/512(基线 510 + 新增 2);
变异闸 7/7 打红并按字节还原(含把判据放宽为「至少一条」、把已结案算作承接、
空集视为已承接、去掉 alarm_status 过滤、两处透传丢失)。
zksy-system 全量另有 2 失败/1 错误为**既有**,与本改动零交集(已按 HEAD 对照确认)。
Kazerin 4 日 前
親
コミット
3772220e66

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

@@ -1,8 +1,14 @@
 package com.zksy.web.controller.cockpit;
 package com.zksy.web.controller.cockpit;
 
 
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentCaptor;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentStatusService;
@@ -19,6 +25,7 @@ import org.junit.jupiter.api.Test;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.List;
 import java.util.List;
 
 
@@ -40,6 +47,7 @@ class BusinessCockpitDeviceFactResolverTest {
     private EquipmentStatusService equipmentStatusService;
     private EquipmentStatusService equipmentStatusService;
     private EarlyWarningService earlyWarningService;
     private EarlyWarningService earlyWarningService;
     private WorkOrderService workOrderService;
     private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
     private BusinessCockpitDeviceFactResolver resolver;
     private BusinessCockpitDeviceFactResolver resolver;
 
 
     @BeforeEach
     @BeforeEach
@@ -47,8 +55,9 @@ class BusinessCockpitDeviceFactResolverTest {
         equipmentStatusService = mock(EquipmentStatusService.class);
         equipmentStatusService = mock(EquipmentStatusService.class);
         earlyWarningService = mock(EarlyWarningService.class);
         earlyWarningService = mock(EarlyWarningService.class);
         workOrderService = mock(WorkOrderService.class);
         workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
         resolver = new BusinessCockpitDeviceFactResolver(
         resolver = new BusinessCockpitDeviceFactResolver(
-                equipmentStatusService, earlyWarningService, workOrderService);
+                equipmentStatusService, earlyWarningService, workOrderService, alarmDataService);
     }
     }
 
 
     @Test
     @Test
@@ -75,10 +84,168 @@ class BusinessCockpitDeviceFactResolverTest {
         assertEquals("VALID", fact.getCoordinateValidation().getState());
         assertEquals("VALID", fact.getCoordinateValidation().getState());
         assertNull(fact.getCoordinateValidation().getReason());
         assertNull(fact.getCoordinateValidation().getReason());
         assertTrue(fact.getCoordinateValidation().isRenderable());
         assertTrue(fact.getCoordinateValidation().isRenderable());
+        // 本例未桩 alarm_data → 未处理报警不可知 → 保守为「未承接」,仍判 ALARM。
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(),
+                "读不到未处理报警行时不得推定已承接");
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    // ---- 票 52:单设备「全部未处理报警都由未结束工单承接」时判工单处置 ----
+
+    @Test
+    void allUnhandledAlarmsCoveredByOpenWorkOrdersResolveToWorkOrder() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-1")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        // 两条未处理报警都指向未结束工单(0 待派单 / 3 处理中)。
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenReturn(Arrays.asList(alarm("A-1", 0, 101L), alarm("A-2", 0, 102L)));
+        when(workOrderService.list(any(Wrapper.class)))
+                .thenReturn(Arrays.asList(order(101L, 0), order(102L, 3)));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-1"), null);
+
+        assertTrue(fact.isAlarmsCoveredByOpenWorkOrders(), "全部未处理报警都有未结束工单承接");
+        assertEquals(CockpitMonitoringPointStatus.WORK_ORDER,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact),
+                "承接完整时单设备状态=工单处置(高于报警/预警)");
+    }
+
+    @Test
+    void unhandledAlarmWithoutWorkOrderStaysAlarm() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-2")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(alarm("A-1", 0, null)));
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-2"), null);
+
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(), "有未处理报警无工单承接");
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void mixedCoverageStaysAlarm() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-3")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        // 一条有未结束工单、一条没有 → 混合,不成立(要求「每一条」)。
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenReturn(Arrays.asList(alarm("A-1", 0, 101L), alarm("A-2", 0, null)));
+        when(workOrderService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(order(101L, 0)));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-3"), null);
+
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(), "混合覆盖不满足「全部」");
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void closedWorkOrderDoesNotCountAsCoverage() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-4")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        // 关联的是已结案工单(5)→ 不算承接,处置并未在进行。
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(alarm("A-1", 0, 101L)));
+        when(workOrderService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(order(101L, 5)));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-4"), null);
+
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(), "已结案工单不是未结束承接");
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void cancelledWorkOrderDoesNotCountAsCoverage() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-5")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(alarm("A-1", 0, 101L)));
+        when(workOrderService.list(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(order(101L, 7)));
+
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(
+                        resolver.resolve(device("DEV-T52-5"), null)),
+                "0-based 的 7=已取消 是终态,不算承接");
+    }
+
+    @Test
+    void alarmStatusFlagWithoutUnhandledAlarmRowsStaysAlarm() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-6")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        // 设备报警标志为真,但查不到未处理报警行(历史流水全部已处理)→ 不推定已承接。
+        when(alarmDataService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-6"), null);
+
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(), "查不到未处理报警 ≠ 已承接");
         assertEquals(CockpitMonitoringPointStatus.ALARM,
         assertEquals(CockpitMonitoringPointStatus.ALARM,
                 CockpitMonitoringPointStatusResolver.deviceStatus(fact));
                 CockpitMonitoringPointStatusResolver.deviceStatus(fact));
     }
     }
 
 
+    @Test
+    void alarmDataReadFailureDegradesToAlarmNotWorkOrder() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-7")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(alarmDataService.list(any(Wrapper.class)))
+                .thenThrow(new RuntimeException("db down"));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-T52-7"), null);
+
+        assertFalse(fact.isAlarmsCoveredByOpenWorkOrders(), "读失败按未承接保守降级");
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void noAlarmFlagKeepsExistingOrder() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-8")).thenReturn(status(1, 0, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(alarmDataService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        assertEquals(CockpitMonitoringPointStatus.NORMAL,
+                CockpitMonitoringPointStatusResolver.deviceStatus(
+                        resolver.resolve(device("DEV-T52-8"), null)),
+                "无报警时维持现状(正常)");
+    }
+
+    /**
+     * 报警范围必须**只取未处理**({@code alarm_status = 0})。夹具用同一个 mock 返回,
+     * 若不钉住查询条件,把过滤去掉也测不出来(变异 M5 就是这么暴露的)。
+     */
+    @Test
+    void unhandledAlarmQueryFiltersOnAlarmStatusZero() {
+        when(equipmentStatusService.getByDeviceCode("DEV-T52-9")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(alarmDataService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        resolver.resolve(device("DEV-T52-9"), null);
+
+        // 纯单测没有 Spring/MyBatis 启动,LambdaQueryWrapper 的实体缓存需手动登记,
+        // 否则 getSqlSegment() 取列名会抛 "can not find lambda cache"。
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), AlarmData.class);
+
+        ArgumentCaptor<Wrapper> alarmQuery = ArgumentCaptor.forClass(Wrapper.class);
+        verify(alarmDataService).list(alarmQuery.capture());
+        // 现场实现用的是 LambdaQueryWrapper,与 QueryWrapper 是并列实现;
+        // 条件与取值都在共同父类 AbstractWrapper 上。
+        AbstractWrapper<?, ?, ?> captured = (AbstractWrapper<?, ?, ?>) alarmQuery.getValue();
+        String segment = captured.getSqlSegment();
+        assertTrue(segment.contains("device_code"), "报警按设备编码取:" + segment);
+        assertTrue(segment.contains("alarm_status"), "必须只取未处理报警(alarm_status):" + segment);
+        assertTrue(captured.getParamNameValuePairs().containsValue(0), "未处理=alarm_status 0");
+    }
+
     @Test
     @Test
     void offlineStatusResolvesToOffline() {
     void offlineStatusResolvesToOffline() {
         when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
         when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
@@ -182,8 +349,22 @@ class BusinessCockpitDeviceFactResolverTest {
     }
     }
 
 
     private static WorkOrder order(int status) {
     private static WorkOrder order(int status) {
+        return order(null, status);
+    }
+
+    private static WorkOrder order(Long orderId, int status) {
         WorkOrder order = new WorkOrder();
         WorkOrder order = new WorkOrder();
+        order.setOrderId(orderId);
         order.setOrderStatus(status);
         order.setOrderStatus(status);
         return order;
         return order;
     }
     }
+
+    private static AlarmData alarm(String id, int alarmStatus, Long workOrderId) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId(id);
+        alarm.setDeviceCode("DEV");
+        alarm.setAlarmStatus(alarmStatus);
+        alarm.setWorkOrderId(workOrderId);
+        return alarm;
+    }
 }
 }

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

@@ -205,6 +205,38 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
                 "statusTime 仍取设备状态更新时间,不被 createdAt 顶替");
                 "statusTime 仍取设备状态更新时间,不被 createdAt 顶替");
     }
     }
 
 
+    /**
+     * 票 52:单设备判定所需的新事实必须**随快照透传**到分布出口,
+     * 否则服务层拿不到它、设备状态会退回报警优先(性质同票 40 的「同源引用被 spread 破坏」)。
+     */
+    @Test
+    void carriedAlarmCoverageReachesSmartDeviceSnapshot() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Collections.singletonList(type(22, "22", "可燃气体检测仪", "3")));
+        EquipmentBase free = device("D-1", "FREE-1", "独立燃气计", "22",
+                new BigDecimal("110.440"), new BigDecimal("28.450"), null);
+        stubDevices(Collections.singletonList(free), Collections.emptyList());
+        when(deviceFactResolver.resolve(any(), anyString()))
+                .thenReturn(factWithCoverage("FREE-1", true, true, false));
+
+        CockpitMonitoringDistributionSnapshot snapshot = adapter.load(null);
+
+        CockpitMonitoringDistributionSnapshot.SourceSmartDevice only = snapshot.getSmartDevices().get(0);
+        assertTrue(only.isAlarm(), "设备报警标志照旧透传");
+        assertTrue(only.isAlarmsCoveredByOpenWorkOrders(), "承接事实必须透传到分布快照");
+    }
+
+    private static CockpitMonitoringPointDeviceSnapshot factWithCoverage(
+            String code, boolean online, boolean alarmFlag, boolean workOrder) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                code, code, "类型名",
+                new BigDecimal("110.4"), new BigDecimal("28.4"),
+                com.zksy.cockpit.foundation.CockpitCoordinateValidator.validate(
+                        new BigDecimal("110.4"), new BigDecimal("28.4"), false),
+                online, alarmFlag, false, workOrder,
+                Collections.emptyList(), null, true, true);
+    }
+
     private static CockpitMonitoringPointDeviceSnapshot fact(
     private static CockpitMonitoringPointDeviceSnapshot fact(
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
         return new CockpitMonitoringPointDeviceSnapshot(
         return new CockpitMonitoringPointDeviceSnapshot(

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

@@ -680,6 +680,33 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.createdAt").value("2026-04-01T08:00:00+08:00"));
                 .andExpect(jsonPath("$.data.createdAt").value("2026-04-01T08:00:00+08:00"));
     }
     }
 
 
+    /**
+     * 票 52:`effectiveDevice(...)` 会把快照设备**重建**一次(叠加实时运行态)。
+     * 若重建时丢掉「全部未处理报警都已由未结束工单承接」这一事实,出口就会退回 ALARM
+     * —— 快照层测得再对也会在服务层被抹掉,故这里直接钉服务层出口的 **WORK_ORDER**。
+     */
+    @Test
+    void distributionKeepsWorkOrderStatusWhenAlarmsAreCoveredByOpenWorkOrders() throws Exception {
+        CockpitMonitoringPointDeviceSnapshot covered = new CockpitMonitoringPointDeviceSnapshot(
+                "GAS-COVERED", "燃气计-已承接", "燃气浓度计",
+                new BigDecimal("110.130"), new BigDecimal("28.440"),
+                com.zksy.cockpit.foundation.CockpitCoordinateValidator.validate(
+                        new BigDecimal("110.130"), new BigDecimal("28.440"), true),
+                true, true, false, true,
+                Collections.singletonList("浓度超限"), SNAPSHOT_AT, true, true);
+        when(adapter.load(null)).thenReturn(new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
+                Collections.singletonList(point("P-COVERED", "燃气监测点-已承接", 0, "GAS_SENSOR",
+                        "110.130", "28.440", covered)),
+                Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                // 单设备点位:设备判 WORK_ORDER(3) ⇒ 聚合 min(priority) 也是 WORK_ORDER;
+                // 若服务层重建时丢了承接事实,设备退回 ALARM(1),这里会读成 ALARM。
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("WORK_ORDER"));
+    }
+
     private static CockpitMonitoringPointDeviceSnapshot device(
     private static CockpitMonitoringPointDeviceSnapshot device(
             String code, String name, boolean online, boolean alarm, boolean warning,
             String code, String name, boolean online, boolean alarm, boolean warning,
             boolean workOrderInProgress, List<String> statusReasons) {
             boolean workOrderInProgress, List<String> statusReasons) {

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

@@ -144,7 +144,8 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     fact.getStatusTime(),
                     fact.getStatusTime(),
                     fact.isStatusPublished(),
                     fact.isStatusPublished(),
                     // 数据时间=设备自身记录创建时间(equipment_base.create_time)
                     // 数据时间=设备自身记录创建时间(equipment_base.create_time)
-                    CockpitCreatedAt.format(device.getCreateTime())));
+                    CockpitCreatedAt.format(device.getCreateTime()),
+                    fact.isAlarmsCoveredByOpenWorkOrders()));
         }
         }
 
 
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()

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

@@ -335,7 +335,8 @@ public class CockpitMonitoringDistributionService {
                         device.isWarning(),
                         device.isWarning(),
                         device.isWorkOrderInProgress(),
                         device.isWorkOrderInProgress(),
                         device.getStatusReasons(),
                         device.getStatusReasons(),
-                        device.getStatusTime(), device.isStatusPublished());
+                        device.getStatusTime(), device.isStatusPublished(),
+                        device.isAlarmsCoveredByOpenWorkOrders());
                 CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
                 CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
                 String publishedStatus = publishedStatus(applied);
                 String publishedStatus = publishedStatus(applied);
                 CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
                 CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
@@ -480,7 +481,8 @@ public class CockpitMonitoringDistributionService {
                 source.getStatusTime(),
                 source.getStatusTime(),
                 runtime == null || runtime.serverUpdatedAt == null
                 runtime == null || runtime.serverUpdatedAt == null
                         ? source.isStatusPublished()
                         ? source.isStatusPublished()
-                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState));
+                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState),
+                source.isAlarmsCoveredByOpenWorkOrders());
     }
     }
 
 
     /**
     /**

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

@@ -197,6 +197,11 @@ public class CockpitMonitoringDistributionSnapshot {
         private final boolean statusPublished;
         private final boolean statusPublished;
         /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
         /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
         private final String createdAt;
         private final String createdAt;
+        /**
+         * 该设备全部未处理报警是否都已由未结束工单承接(维护者 2026-10-17 裁定);
+         * 只作用于单设备状态解析,见 {@code CockpitMonitoringPointStatusResolver.deviceStatus}。
+         */
+        private final boolean alarmsCoveredByOpenWorkOrders;
 
 
         public SourceSmartDevice(
         public SourceSmartDevice(
                 String deviceCode,
                 String deviceCode,
@@ -229,6 +234,16 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 List<String> statusReasons, String statusTime, boolean statusPublished, String createdAt) {
                 List<String> statusReasons, String statusTime, boolean statusPublished, String createdAt) {
+            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified,
+                    online, alarm, warning, workOrderInProgress, statusReasons, statusTime,
+                    statusPublished, createdAt, false);
+        }
+
+        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
+                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
+                boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+                List<String> statusReasons, String statusTime, boolean statusPublished, String createdAt,
+                boolean alarmsCoveredByOpenWorkOrders) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = district;
             this.district = district;
@@ -244,6 +259,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.statusTime = statusTime;
             this.statusTime = statusTime;
             this.statusPublished = statusPublished;
             this.statusPublished = statusPublished;
             this.createdAt = createdAt;
             this.createdAt = createdAt;
+            this.alarmsCoveredByOpenWorkOrders = alarmsCoveredByOpenWorkOrders;
         }
         }
 
 
         public String getDeviceCode() {
         public String getDeviceCode() {
@@ -297,6 +313,8 @@ public class CockpitMonitoringDistributionSnapshot {
 
 
         public boolean isStatusPublished() { return statusPublished; }
         public boolean isStatusPublished() { return statusPublished; }
 
 
+        public boolean isAlarmsCoveredByOpenWorkOrders() { return alarmsCoveredByOpenWorkOrders; }
+
         public String getStatusTime() {
         public String getStatusTime() {
             return statusTime;
             return statusTime;
         }
         }

+ 60 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java

@@ -2,6 +2,8 @@ package com.zksy.cockpit.point;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
@@ -19,7 +21,9 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 
 /**
 /**
  * 从现网既有表组合出「单台设备的实时事实」(在线/报警/预警/在途工单),供各只读适配器复用。
  * 从现网既有表组合出「单台设备的实时事实」(在线/报警/预警/在途工单),供各只读适配器复用。
@@ -37,17 +41,23 @@ public class BusinessCockpitDeviceFactResolver {
     private static final Set<String> OPEN_WARNING_STATUS = new LinkedHashSet<>(
     private static final Set<String> OPEN_WARNING_STATUS = new LinkedHashSet<>(
             Arrays.asList("PENDING", "PROCESSING", "HANDLING", "RELEASED"));
             Arrays.asList("PENDING", "PROCESSING", "HANDLING", "RELEASED"));
 
 
+    /** 报警记录未处理:{@code alarm_data.alarm_status},见 {@code AlarmRecordStatusEnum.UNHANDLED}。 */
+    private static final Integer UNHANDLED_ALARM_STATUS = 0;
+
     private final EquipmentStatusService equipmentStatusService;
     private final EquipmentStatusService equipmentStatusService;
     private final EarlyWarningService earlyWarningService;
     private final EarlyWarningService earlyWarningService;
     private final WorkOrderService workOrderService;
     private final WorkOrderService workOrderService;
+    private final AlarmDataService alarmDataService;
 
 
     public BusinessCockpitDeviceFactResolver(
     public BusinessCockpitDeviceFactResolver(
             EquipmentStatusService equipmentStatusService,
             EquipmentStatusService equipmentStatusService,
             EarlyWarningService earlyWarningService,
             EarlyWarningService earlyWarningService,
-            WorkOrderService workOrderService) {
+            WorkOrderService workOrderService,
+            AlarmDataService alarmDataService) {
         this.equipmentStatusService = equipmentStatusService;
         this.equipmentStatusService = equipmentStatusService;
         this.earlyWarningService = earlyWarningService;
         this.earlyWarningService = earlyWarningService;
         this.workOrderService = workOrderService;
         this.workOrderService = workOrderService;
+        this.alarmDataService = alarmDataService;
     }
     }
 
 
     public CockpitMonitoringPointDeviceSnapshot resolve(EquipmentBase device, String deviceTypeName) {
     public CockpitMonitoringPointDeviceSnapshot resolve(EquipmentBase device, String deviceTypeName) {
@@ -88,7 +98,55 @@ public class BusinessCockpitDeviceFactResolver {
                         ? null
                         ? null
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
                                 .atZone(BUSINESS_ZONE).toOffsetDateTime()),
                                 .atZone(BUSINESS_ZONE).toOffsetDateTime()),
-                status != null);
+                status != null,
+                alarmsCoveredByOpenWorkOrders(deviceCode));
+    }
+
+    /**
+     * 「该设备的全部未处理报警都已由未结束工单承接」(维护者 2026-10-17 裁定)。
+     *
+     * <p>取 {@code alarm_data.alarm_status = 0} 的行:**每一行**都要有 {@code work_order_id},
+     * 且对应工单存在且未结束({@code order_status ∈ {0..4,6}})。无未处理报警行、任一行缺工单、
+     * 或对应工单已结案/已取消 → {@code false},由调用方维持现行报警优先序——「查不到承接」
+     * 不等于「已承接」,不把数据缺口渲染成处置中。
+     *
+     * <p>只影响单设备状态解析;点位聚合({@code resolve()})与枚举优先级不变。
+     */
+    private boolean alarmsCoveredByOpenWorkOrders(String deviceCode) {
+        if (isBlank(deviceCode)) {
+            return false;
+        }
+        try {
+            List<AlarmData> unhandled = safeList(alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
+                    .eq(AlarmData::getDeviceCode, deviceCode.trim())
+                    .eq(AlarmData::getAlarmStatus, UNHANDLED_ALARM_STATUS)));
+            if (unhandled.isEmpty()) {
+                return false;
+            }
+            Set<Long> orderIds = unhandled.stream()
+                    .map(AlarmData::getWorkOrderId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            // 任何一条未处理报警没有工单承接,就不成立(不是「至少一条」)。
+            if (orderIds.size() != unhandled.size()) {
+                return false;
+            }
+            List<WorkOrder> orders = safeList(workOrderService.list(
+                    new QueryWrapper<WorkOrder>().in("order_id", orderIds)));
+            Set<Long> openOrderIds = orders.stream()
+                    .filter(order -> order != null && order.getOrderId() != null
+                            && isOpenOrderStatus(order.getOrderStatus()))
+                    .map(WorkOrder::getOrderId)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            return openOrderIds.containsAll(orderIds);
+        } catch (Exception ignored) {
+            // 读失败按「未承接」保守降级,不把故障渲染成处置中。
+            return false;
+        }
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
     }
     }
 
 
     private boolean hasOpenWarning(String deviceCode) {
     private boolean hasOpenWarning(String deviceCode) {

+ 5 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDetailService.java

@@ -223,7 +223,11 @@ public class CockpitMonitoringPointDetailService {
                 device.isWarning(),
                 device.isWarning(),
                 device.isWorkOrderInProgress(),
                 device.isWorkOrderInProgress(),
                 device.getStatusReasons(),
                 device.getStatusReasons(),
-                device.getStatusTime());
+                device.getStatusTime(),
+                // 归一化沿用既有口径(statusPublished 恒 true),只补上单设备判定所需的新事实,
+                // 否则点位详情的设备状态会与地图快照分叉。
+                true,
+                device.isAlarmsCoveredByOpenWorkOrders());
     }
     }
 
 
     /**
     /**

+ 20 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java

@@ -20,6 +20,15 @@ public class CockpitMonitoringPointDeviceSnapshot {
     private final List<String> statusReasons;
     private final List<String> statusReasons;
     private final String statusTime;
     private final String statusTime;
     private final boolean statusPublished;
     private final boolean statusPublished;
+    /**
+     * 该设备的**全部未处理报警**是否都已由**未结束工单**承接(维护者 2026-10-17 裁定)。
+     *
+     * <p>判定口径:取 {@code alarm_data.alarm_status = 0} 的行;每一行都要有
+     * {@code work_order_id},且对应工单 {@code order_status ∈ {0..4,6}}(未结束)。
+     * 无未处理报警行、或任一行不满足 → {@code false}(「查不到承接」不等于「已承接」)。
+     * 该事实只影响**单设备**状态解析,不改点位聚合与枚举优先级。
+     */
+    private final boolean alarmsCoveredByOpenWorkOrders;
 
 
     public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
     public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
@@ -49,6 +58,15 @@ public class CockpitMonitoringPointDeviceSnapshot {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             List<String> statusReasons, String statusTime, boolean statusPublished) {
             List<String> statusReasons, String statusTime, boolean statusPublished) {
+        this(deviceCode, deviceName, deviceTypeName, longitude, latitude, coordinateValidation,
+                online, alarm, warning, workOrderInProgress, statusReasons, statusTime, statusPublished, false);
+    }
+
+    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
+            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
+            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+            List<String> statusReasons, String statusTime, boolean statusPublished,
+            boolean alarmsCoveredByOpenWorkOrders) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceName = deviceName;
         this.deviceTypeName = deviceTypeName;
         this.deviceTypeName = deviceTypeName;
@@ -62,6 +80,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
         this.statusReasons = statusReasons;
         this.statusReasons = statusReasons;
         this.statusTime = statusTime;
         this.statusTime = statusTime;
         this.statusPublished = statusPublished;
         this.statusPublished = statusPublished;
+        this.alarmsCoveredByOpenWorkOrders = alarmsCoveredByOpenWorkOrders;
     }
     }
 
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
@@ -77,6 +96,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
     public List<String> getStatusReasons() { return statusReasons; }
     public List<String> getStatusReasons() { return statusReasons; }
     public String getStatusTime() { return statusTime; }
     public String getStatusTime() { return statusTime; }
     public boolean isStatusPublished() { return statusPublished; }
     public boolean isStatusPublished() { return statusPublished; }
+    public boolean isAlarmsCoveredByOpenWorkOrders() { return alarmsCoveredByOpenWorkOrders; }
     /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
     /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
     public String getPublishedDeviceStatus() {
     public String getPublishedDeviceStatus() {
         return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null;
         return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null;

+ 13 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusResolver.java

@@ -61,9 +61,21 @@ public final class CockpitMonitoringPointStatusResolver {
                 count(safeDevices, CockpitMonitoringPointStatus.OFFLINE));
                 count(safeDevices, CockpitMonitoringPointStatus.OFFLINE));
     }
     }
 
 
+    /**
+     * 单设备业务状态(维护者 2026-10-17 裁定)。
+     *
+     * <p>报警**且**该设备全部未处理报警都已由未结束工单承接时 → {@code WORK_ORDER}
+     * (处置在进行,优先于报警/预警);否则维持现行序
+     * {@code alarm → warning → workOrderInProgress → online ? NORMAL : OFFLINE}。
+     *
+     * <p>该裁定只作用于**单设备**判定;点位聚合 {@code resolve()} 仍取
+     * {@code min(priority)},故枚举优先级与前端词表不变。
+     */
     public static CockpitMonitoringPointStatus deviceStatus(CockpitMonitoringPointDeviceSnapshot device) {
     public static CockpitMonitoringPointStatus deviceStatus(CockpitMonitoringPointDeviceSnapshot device) {
         if (device.isAlarm()) {
         if (device.isAlarm()) {
-            return CockpitMonitoringPointStatus.ALARM;
+            return device.isAlarmsCoveredByOpenWorkOrders()
+                    ? CockpitMonitoringPointStatus.WORK_ORDER
+                    : CockpitMonitoringPointStatus.ALARM;
         }
         }
         if (device.isWarning()) {
         if (device.isWarning()) {
             return CockpitMonitoringPointStatus.WARNING;
             return CockpitMonitoringPointStatus.WARNING;