Procházet zdrojové kódy

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 před 4 dny
rodič
revize
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;
 
+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.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 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.EquipmentStatus;
 import com.zksy.base.service.EquipmentStatusService;
@@ -19,6 +25,7 @@ import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
@@ -40,6 +47,7 @@ class BusinessCockpitDeviceFactResolverTest {
     private EquipmentStatusService equipmentStatusService;
     private EarlyWarningService earlyWarningService;
     private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
     private BusinessCockpitDeviceFactResolver resolver;
 
     @BeforeEach
@@ -47,8 +55,9 @@ class BusinessCockpitDeviceFactResolverTest {
         equipmentStatusService = mock(EquipmentStatusService.class);
         earlyWarningService = mock(EarlyWarningService.class);
         workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
         resolver = new BusinessCockpitDeviceFactResolver(
-                equipmentStatusService, earlyWarningService, workOrderService);
+                equipmentStatusService, earlyWarningService, workOrderService, alarmDataService);
     }
 
     @Test
@@ -75,10 +84,168 @@ class BusinessCockpitDeviceFactResolverTest {
         assertEquals("VALID", fact.getCoordinateValidation().getState());
         assertNull(fact.getCoordinateValidation().getReason());
         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,
                 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
     void offlineStatusResolvesToOffline() {
         when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
@@ -182,8 +349,22 @@ class BusinessCockpitDeviceFactResolverTest {
     }
 
     private static WorkOrder order(int status) {
+        return order(null, status);
+    }
+
+    private static WorkOrder order(Long orderId, int status) {
         WorkOrder order = new WorkOrder();
+        order.setOrderId(orderId);
         order.setOrderStatus(status);
         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 顶替");
     }
 
+    /**
+     * 票 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(
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
         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"));
     }
 
+    /**
+     * 票 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(
             String code, String name, boolean online, boolean alarm, boolean warning,
             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.isStatusPublished(),
                     // 数据时间=设备自身记录创建时间(equipment_base.create_time)
-                    CockpitCreatedAt.format(device.getCreateTime())));
+                    CockpitCreatedAt.format(device.getCreateTime()),
+                    fact.isAlarmsCoveredByOpenWorkOrders()));
         }
 
         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.isWorkOrderInProgress(),
                         device.getStatusReasons(),
-                        device.getStatusTime(), device.isStatusPublished());
+                        device.getStatusTime(), device.isStatusPublished(),
+                        device.isAlarmsCoveredByOpenWorkOrders());
                 CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
                 String publishedStatus = publishedStatus(applied);
                 CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
@@ -480,7 +481,8 @@ public class CockpitMonitoringDistributionService {
                 source.getStatusTime(),
                 runtime == null || runtime.serverUpdatedAt == null
                         ? 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;
         /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
         private final String createdAt;
+        /**
+         * 该设备全部未处理报警是否都已由未结束工单承接(维护者 2026-10-17 裁定);
+         * 只作用于单设备状态解析,见 {@code CockpitMonitoringPointStatusResolver.deviceStatus}。
+         */
+        private final boolean alarmsCoveredByOpenWorkOrders;
 
         public SourceSmartDevice(
                 String deviceCode,
@@ -229,6 +234,16 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 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.deviceName = deviceName;
             this.district = district;
@@ -244,6 +259,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.statusTime = statusTime;
             this.statusPublished = statusPublished;
             this.createdAt = createdAt;
+            this.alarmsCoveredByOpenWorkOrders = alarmsCoveredByOpenWorkOrders;
         }
 
         public String getDeviceCode() {
@@ -297,6 +313,8 @@ public class CockpitMonitoringDistributionSnapshot {
 
         public boolean isStatusPublished() { return statusPublished; }
 
+        public boolean isAlarmsCoveredByOpenWorkOrders() { return alarmsCoveredByOpenWorkOrders; }
+
         public String getStatusTime() {
             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.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.EquipmentStatus;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
@@ -19,7 +21,9 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Objects;
 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<>(
             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 EarlyWarningService earlyWarningService;
     private final WorkOrderService workOrderService;
+    private final AlarmDataService alarmDataService;
 
     public BusinessCockpitDeviceFactResolver(
             EquipmentStatusService equipmentStatusService,
             EarlyWarningService earlyWarningService,
-            WorkOrderService workOrderService) {
+            WorkOrderService workOrderService,
+            AlarmDataService alarmDataService) {
         this.equipmentStatusService = equipmentStatusService;
         this.earlyWarningService = earlyWarningService;
         this.workOrderService = workOrderService;
+        this.alarmDataService = alarmDataService;
     }
 
     public CockpitMonitoringPointDeviceSnapshot resolve(EquipmentBase device, String deviceTypeName) {
@@ -88,7 +98,55 @@ public class BusinessCockpitDeviceFactResolver {
                         ? null
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
                                 .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) {

+ 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.isWorkOrderInProgress(),
                 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 String statusTime;
     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,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
@@ -49,6 +58,15 @@ public class CockpitMonitoringPointDeviceSnapshot {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             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.deviceName = deviceName;
         this.deviceTypeName = deviceTypeName;
@@ -62,6 +80,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
         this.statusReasons = statusReasons;
         this.statusTime = statusTime;
         this.statusPublished = statusPublished;
+        this.alarmsCoveredByOpenWorkOrders = alarmsCoveredByOpenWorkOrders;
     }
 
     public String getDeviceCode() { return deviceCode; }
@@ -77,6 +96,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
     public List<String> getStatusReasons() { return statusReasons; }
     public String getStatusTime() { return statusTime; }
     public boolean isStatusPublished() { return statusPublished; }
+    public boolean isAlarmsCoveredByOpenWorkOrders() { return alarmsCoveredByOpenWorkOrders; }
     /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
     public String getPublishedDeviceStatus() {
         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));
     }
 
+    /**
+     * 单设备业务状态(维护者 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) {
         if (device.isAlarm()) {
-            return CockpitMonitoringPointStatus.ALARM;
+            return device.isAlarmsCoveredByOpenWorkOrders()
+                    ? CockpitMonitoringPointStatus.WORK_ORDER
+                    : CockpitMonitoringPointStatus.ALARM;
         }
         if (device.isWarning()) {
             return CockpitMonitoringPointStatus.WARNING;