Forráskód Böngészése

fix(cockpit): 预警设置端点改为预警自身生命周期统计 (#41)

预警不走工单流程(维护者 2026-10-14 裁定):预警族只给预警自身生命周期读数
(WarningWorkflow 六态状态分布、等级/行业/片区分布、已解除÷总数、按发布时间分桶趋势),
工单口径字段一律 null(不适用),不再桥接 work_order。

报警靠工单闭环:报警族保留工单口径(handled/pending/cancelledCount/workOrderStates/
工单口径处置率),并并存 alarm_status 自身生命周期分布与维度分布。

片区/行业沿 equipment_base 与 equipment_type 树上溯解析后带入快照(服务层无台账)。

契约测试 24/24,全量 Cockpit*Test 479/479。
codex 6 napja
szülő
commit
b047a60d4e

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

@@ -3,6 +3,10 @@ package com.zksy.web.controller.cockpit;
 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.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
@@ -18,6 +22,8 @@ import java.sql.Timestamp;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -31,6 +37,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
     private AlarmDataService alarmDataService;
     private AlarmWarningRelMapper alarmWarningRelMapper;
     private WorkOrderService workOrderService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
     private BusinessCockpitOverviewWarningSettingsAdapter adapter;
 
     @BeforeEach
@@ -39,16 +47,28 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarmDataService = mock(AlarmDataService.class);
         alarmWarningRelMapper = mock(AlarmWarningRelMapper.class);
         workOrderService = mock(WorkOrderService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
         adapter = new BusinessCockpitOverviewWarningSettingsAdapter(
-                earlyWarningService, alarmDataService, alarmWarningRelMapper, workOrderService);
+                earlyWarningService, alarmDataService, alarmWarningRelMapper, workOrderService,
+                equipmentBaseService, equipmentTypeService);
+        // 默认无设备/类型台账,各用例按需覆盖。
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
     }
 
+    /**
+     * 预警不再桥接工单(维护者 2026-10-14 裁定):即便库里有工单,预警事件也不带工单事实,
+     * 只带自身生命周期;报警仍保留工单回联。
+     */
     @Test
-    void bridgesWarningWorkOrderThroughRelationAndAggregatesTrend() {
+    void warningCarriesItsOwnLifecycleAndNoWorkOrderWhileAlarmKeepsWorkOrderLink() {
         EarlyWarning warning = new EarlyWarning();
         warning.setWarningId("WARN-ID-001");
         warning.setWarningNo("W-001");
         warning.setDeviceCode("GAS-001");
+        warning.setStatus("CLOSED");
+        warning.setWarningLevel("2");
         warning.setPublishTime(LocalDateTime.parse("2026-09-02T09:00:00"));
         when(earlyWarningService.list()).thenReturn(Collections.singletonList(warning));
 
@@ -56,6 +76,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarm.setId("A-001");
         alarm.setDeviceCode("GAS-001");
         alarm.setWorkOrderId(1001L);
+        alarm.setAlarmStatus(1);
+        alarm.setAlarmLevel(3);
         alarm.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
         when(alarmDataService.list()).thenReturn(Collections.singletonList(alarm));
 
@@ -76,30 +98,78 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
 
         assertEquals(2, snapshot.getEvents().size());
-        CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
-        assertEquals("ALARM", alarmEvent.getEventFamily());
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = event(snapshot, "ALARM");
         assertEquals("A-001", alarmEvent.getEventCode());
         assertEquals("WO-001", alarmEvent.getWorkOrderCode());
         assertEquals(Integer.valueOf(5), alarmEvent.getWorkOrderState());
+        assertEquals("HANDLED", alarmEvent.getStatus());
+        assertEquals(Integer.valueOf(3), alarmEvent.getLevel());
 
-        CockpitOverviewWarningSettingsSnapshot.SourceEvent warningEvent = snapshot.getEvents().get(1);
-        assertEquals("WARNING", warningEvent.getEventFamily());
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent warningEvent = event(snapshot, "WARNING");
         assertEquals("W-001", warningEvent.getEventCode());
-        assertEquals("WO-001", warningEvent.getWorkOrderCode());
-        assertEquals(Integer.valueOf(5), warningEvent.getWorkOrderState());
-
-        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint warningTrend = snapshot.getTrendPoints().stream()
-                .filter(point -> "WARNING".equals(point.getEventFamily()))
-                .findFirst().orElseThrow(AssertionError::new);
-        assertEquals("2026-09-02", warningTrend.getBucket());
-        assertEquals(1, warningTrend.getTotal());
-        assertEquals(1, warningTrend.getHandled());
-
-        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().stream()
-                .filter(point -> "ALARM".equals(point.getEventFamily()))
-                .findFirst().orElseThrow(AssertionError::new);
-        assertEquals(1, alarmTrend.getTotal());
-        assertEquals(1, alarmTrend.getHandled());
+        assertEquals("CLOSED", warningEvent.getStatus());
+        assertEquals(Integer.valueOf(2), warningEvent.getLevel());
+        // 预警不走工单流程:工单事实一律为空,不因库里有工单就被挂上。
+        assertNull(warningEvent.getWorkOrderCode());
+        assertNull(warningEvent.getWorkOrderState());
+    }
+
+    /** 预警趋势的 handled 按预警自身「已解除」判定,不依赖工单是否结案。 */
+    @Test
+    void warningTrendHandledCountsClosedWarningsNotClosedWorkOrders() {
+        EarlyWarning closed = warning("W-1", "CLOSED", "2026-09-02T09:00:00");
+        EarlyWarning processing = warning("W-2", "PROCESSING", "2026-09-02T11:00:00");
+        when(earlyWarningService.list()).thenReturn(Arrays.asList(closed, processing));
+        when(alarmDataService.list()).thenReturn(Collections.emptyList());
+        when(alarmWarningRelMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list()).thenReturn(Collections.emptyList());
+
+        CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
+
+        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint trend =
+                snapshot.getTrendPoints().stream()
+                        .filter(point -> "WARNING".equals(point.getEventFamily()))
+                        .findFirst().orElseThrow(AssertionError::new);
+        assertEquals("2026-09-02", trend.getBucket());
+        assertEquals(2, trend.getTotal());
+        assertEquals(1, trend.getHandled());
+    }
+
+    /** 片区/行业沿设备解析带入快照;无设备时如实留空,不冒充。 */
+    @Test
+    void districtAndIndustryAreResolvedThroughTheDeviceAndStayNullWithoutOne() {
+        EquipmentType root = new EquipmentType();
+        root.setId(3);
+        root.setParentId(0);
+        root.setTypeName("燃气");
+        when(equipmentTypeService.list()).thenReturn(Collections.singletonList(root));
+
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId("DEV-ID-1");
+        device.setEquipmentCode("GAS-001");
+        device.setEquipmentTypeId(3);
+        device.setDistrict(1);
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device));
+
+        EarlyWarning known = warning("W-1", "PENDING", "2026-09-02T09:00:00");
+        known.setDeviceCode("GAS-001");
+        EarlyWarning unknownDevice = warning("W-2", "PENDING", "2026-09-02T09:00:00");
+        unknownDevice.setDeviceCode("NOPE-999");
+        when(earlyWarningService.list()).thenReturn(Arrays.asList(known, unknownDevice));
+        when(alarmDataService.list()).thenReturn(Collections.emptyList());
+        when(alarmWarningRelMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list()).thenReturn(Collections.emptyList());
+
+        CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent resolved = event(snapshot, "WARNING", "W-1");
+        assertEquals(Integer.valueOf(1), resolved.getDistrictCode());
+        assertEquals("GAS", resolved.getIndustryCode());
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent unresolved = event(snapshot, "WARNING", "W-2");
+        assertNull(unresolved.getDistrictCode());
+        assertNull(unresolved.getIndustryCode());
     }
 
     @Test
@@ -119,6 +189,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
         assertNull(alarmEvent.getWorkOrderCode());
         assertNull(alarmEvent.getWorkOrderState());
+        // alarm_status 缺失 → 自身生命周期状态如实为 null(服务层进未知桶)。
+        assertNull(alarmEvent.getStatus());
         CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().get(0);
         assertEquals(1, alarmTrend.getTotal());
         assertEquals(0, alarmTrend.getHandled());
@@ -138,4 +210,31 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         assertEquals(0, snapshot.getTrendPoints().size());
         assertNull(snapshot.getUpdatedAt());
     }
-}
+
+    private static EarlyWarning warning(String no, String status, String publishTime) {
+        EarlyWarning warning = new EarlyWarning();
+        warning.setWarningId("ID-" + no);
+        warning.setWarningNo(no);
+        warning.setDeviceCode("GAS-001");
+        warning.setStatus(status);
+        warning.setPublishTime(LocalDateTime.parse(publishTime));
+        return warning;
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String family) {
+        return event(snapshot, family, null);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String family, String eventCode) {
+        List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> matches = snapshot.getEvents().stream()
+                .filter(item -> family.equals(item.getEventFamily()))
+                .filter(item -> eventCode == null || eventCode.equals(item.getEventCode()))
+                .collect(Collectors.toList());
+        if (matches.size() != 1) {
+            throw new AssertionError("expected exactly one " + family + " event, got " + matches.size());
+        }
+        return matches.get(0);
+    }
+}

+ 70 - 31
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsControllerTest.java

@@ -47,8 +47,12 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .build();
     }
 
+    /**
+     * 维护者 2026-10-14 裁定:预警族只给预警自身生命周期,工单口径字段一律 {@code null};
+     * 报警族保留工单口径并并存自身生命周期。
+     */
     @Test
-    void warningSettingsUsesEnvelopeCamelCaseAndDefaultWarningMode() throws Exception {
+    void warningSettingsUsesEnvelopeCamelCaseAndSplitsTheTwoFamilyVocabularies() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
@@ -70,31 +74,55 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                // ---- 预警族:自身生命周期,工单口径字段全为 null ----
                 .andExpect(jsonPath("$.data.warning.total").value(3))
-                .andExpect(jsonPath("$.data.warning.handled").value(1))
-                .andExpect(jsonPath("$.data.warning.pending").value(2))
-                // 处置率改为工单口径(决策票 06):分子=状态 5(1),分母=状态 0–6(5)→ 0.2000。
-                .andExpect(jsonPath("$.data.warning.handlingEfficiency.value").value("0.2000"))
+                .andExpect(jsonPath("$.data.warning.handled").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.pending").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.cancelledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.unlinkedEventCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.workOrderStates").value(nullValue()))
+                // 已解除率=已解除 ÷ 总数 = 1/3(预警自身生命周期,不是工单处置率)。
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.value").value("0.3333"))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.numerator").value(1))
-                .andExpect(jsonPath("$.data.warning.handlingEfficiency.denominator").value(5))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.value").value("0.5000"))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.numerator").value(1))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.denominator").value(2))
-                // 状态分布同为工单口径(逐态补 0)。
-                .andExpect(jsonPath("$.data.warning.workOrderStates['1']").value(1))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['4']").value(2))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['5']").value(1))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['6']").value(1))
-                .andExpect(jsonPath("$.data.warning.unlinkedEventCount").value(0))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.denominator").value(3))
+                // 状态分布=WarningWorkflow 六态,逐态铺满。
+                .andExpect(jsonPath("$.data.warning.statusDistribution", hasSize(6)))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[0].code").value("DRAFT"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[1].code").value("PENDING"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[3].code").value("PROCESSING"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[3].count").value(1))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[5].code").value("CLOSED"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[5].count").value(1))
+                // 等级 1–4 全铺 + 一条等级缺失进未知桶。
+                .andExpect(jsonPath("$.data.warning.levelDistribution", hasSize(5)))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[4].code").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[4].count").value(1))
+                // 行业四正式桶全铺 + 未知桶;片区含恒有的未分区桶。
+                .andExpect(jsonPath("$.data.warning.industryDistribution", hasSize(5)))
+                .andExpect(jsonPath("$.data.warning.districtDistribution", hasSize(4)))
+                .andExpect(jsonPath("$.data.warning.districtDistribution[3].code").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.warning.districtDistribution[3].count").value(1))
+                // ---- 报警族:工单口径保留 ----
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
-                .andExpect(jsonPath("$.data.alarm.handled").value(0))
-                .andExpect(jsonPath("$.data.alarm.pending").value(2))
+                .andExpect(jsonPath("$.data.alarm.handled").value(1))
+                .andExpect(jsonPath("$.data.alarm.pending").value(1))
                 .andExpect(jsonPath("$.data.alarm.cancelledCount").value(0))
-                // 两 family 共用同一份全范围工单读数。
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.value").value("0.2000"))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.numerator").value(1))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.denominator").value(5))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['1']").value(1))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['5']").value(1))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['6']").value(1))
+                // 报警自身生命周期并存。
+                .andExpect(jsonPath("$.data.alarm.statusDistribution", hasSize(2)))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[0].code").value("UNHANDLED"))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[0].count").value(1))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[1].code").value("HANDLED"))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[1].count").value(1))
                 .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.mapFilter.label").value("预警"));
     }
@@ -110,7 +138,8 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.selectedMode").value("ALARM"))
                 .andExpect(jsonPath("$.data.warning.total").value(3))
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
-                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("ALARM"))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("ALARM"))
                 .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
     }
 
@@ -251,16 +280,13 @@ class CockpitOverviewWarningSettingsControllerTest {
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-001", "DEV-001", "WO-001", 1),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-002", "DEV-002", "WO-002", 5),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-003", "DEV-003", "WO-003", 6),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "ALARM", "ALM-001", "DEV-004", "WO-004", 4),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "ALARM", "ALM-002", "DEV-005", "WO-005", 4)),
+                        // 预警:自身生命周期 + 维度;工单事实刻意留空(预警不走工单)。
+                        warning("WRN-001", "PENDING", 2, "GAS", 0),
+                        warning("WRN-002", "CLOSED", 3, "WATER_SUPPLY", 1),
+                        warning("WRN-003", "PROCESSING", null, null, null),
+                        // 报警:保留工单回联,并带自身生命周期。
+                        alarm("ALM-001", "UNHANDLED", 4, 3, "GAS", 0),
+                        alarm("ALM-002", "HANDLED", 5, 2, "GAS", 0)),
                 Arrays.asList(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "WARNING", "2026-09-01", 2, 1),
@@ -278,14 +304,27 @@ class CockpitOverviewWarningSettingsControllerTest {
                                 "ALARM", "2026-08-20", 2, 1),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "ALARM", "2026-08-01", 1, 1)),
-                // 全范围工单(分母来源):状态 1/5/6/4/4 → 分母 5(排除状态 7),分子 1(状态 5)。
+                // 报警族工单口径分母来源:状态 1/5/6/4/4 → 分母 5(排除状态 7),分子 1(状态 5)。
                 Arrays.asList(
                         scopeOrder(1), scopeOrder(5), scopeOrder(6), scopeOrder(4), scopeOrder(4)));
     }
 
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent warning(
+            String code, String status, Integer level, String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "WARNING", code, "DEV-" + code, null, null, status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent alarm(
+            String code, String status, Integer workOrderState, Integer level,
+            String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "ALARM", code, "DEV-" + code, "WO-" + code, workOrderState,
+                status, level, district, industry);
+    }
+
     private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder scopeOrder(Integer state) {
         return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
                 state, "2026-09-01T10:00:00+08:00");
     }
 }
-

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

@@ -12,8 +12,11 @@ import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -31,69 +34,146 @@ class CockpitOverviewWarningSettingsServiceTest {
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
     }
 
+    /**
+     * 维护者 2026-10-14 裁定:预警不走工单流程 → 预警族的工单口径字段**必须为 null**(不适用),
+     * 而不是 0 —— 0 会被读成"真的是零"。
+     */
     @Test
-    void currentEightStateModelSeparatesHandledPendingAndUnlinkedEvents() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningFamilyDropsWorkOrderVocabularyEntirely() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-eight", "WARNING", null, null, null);
-
-        // 事件计数仍按 family 各自统计。
-        assertEquals(Integer.valueOf(10), response.getWarning().getTotal());
-        assertEquals(Integer.valueOf(1), response.getWarning().getHandled());
-        assertEquals(Integer.valueOf(6), response.getWarning().getPending());
-        assertEquals(Integer.valueOf(1), response.getWarning().getCancelledCount());
-        assertEquals(Integer.valueOf(1), response.getWarning().getUnlinkedEventCount());
-        assertEquals(Integer.valueOf(1), response.getWarning().getInvalidWorkOrderStateCount());
-        // 工单状态分布与处置率改为工单口径(决策票 06):逐态 1 张,共 8 张。
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(0));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(1));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(2));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(3));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(4));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(5));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(6));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(7));
-        assertEquals(8, response.getWarning().getWorkOrderStates().size());
-        assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
-                .allMatch(code -> code >= 0 && code <= 7));
-        // 分母=状态 0–6(7 张,状态 7 排除),分子=状态 5(1 张)→ 1/7。
-        assertEquals(Integer.valueOf(1), response.getWarning().getHandlingEfficiency().getNumerator());
-        assertEquals(Integer.valueOf(7), response.getWarning().getHandlingEfficiency().getDenominator());
-        assertEquals("0.1429", response.getWarning().getHandlingEfficiency().getValue().toPlainString());
+                service.load("request-warning", "WARNING", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        assertNull(warning.getHandled());
+        assertNull(warning.getPending());
+        assertNull(warning.getCancelledCount());
+        assertNull(warning.getUnlinkedEventCount());
+        assertNull(warning.getInvalidWorkOrderStateCount());
+        assertNull(warning.getWorkOrderStates());
     }
 
+    /** 预警族状态分布=预警自身生命周期(六态),逐桶计数自洽(合计=总数)。 */
     @Test
-    void bothFamiliesShareTheSameWorkOrderScopedRate() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningStatusDistributionCoversItsOwnLifecycleAndSumsToTotal() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-shared", "WARNING", null, null, null);
+                service.load("request-warning", "WARNING", null, null, null);
 
-        // 分母不要求事件回联 → 两 family 共用同一份全范围工单读数。
-        assertEquals(response.getWarning().getHandlingEfficiency().getDenominator(),
-                response.getAlarm().getHandlingEfficiency().getDenominator());
-        assertEquals(response.getWarning().getHandlingEfficiency().getNumerator(),
-                response.getAlarm().getHandlingEfficiency().getNumerator());
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        assertEquals(6, warning.getTotal());
+        assertEquals(1, count(warning.getStatusDistribution(), "DRAFT"));
+        assertEquals(1, count(warning.getStatusDistribution(), "PENDING"));
+        assertEquals(1, count(warning.getStatusDistribution(), "RECEIVED"));
+        assertEquals(1, count(warning.getStatusDistribution(), "PROCESSING"));
+        assertEquals(1, count(warning.getStatusDistribution(), "HANDLED"));
+        assertEquals(1, count(warning.getStatusDistribution(), "CLOSED"));
+        assertEquals(warning.getTotal(), sum(warning.getStatusDistribution()));
+        // 六态全铺(缺的态是真实 0,不是"没有数据")。
+        assertEquals(6, warning.getStatusDistribution().size());
     }
 
+    /** 预警自身生命周期「已解除率」=已解除 ÷ 总数(1/6),与工单口径无关。 */
     @Test
-    void cancelledWorkOrdersAreExcludedFromTheRateDenominator() {
-        when(adapter.load()).thenReturn(cancelledHeavyFixture());
+    void warningEfficiencyIsItsOwnClosedOverTotalNotAWorkOrderRate() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-cancelled", "WARNING", null, null, null);
+                service.load("request-warning", "WARNING", null, null, null);
 
-        // 只有状态 7(2 张)→ 分母 0,不显示 0%,而是不适用。
-        assertEquals(Integer.valueOf(0), response.getWarning().getHandlingEfficiency().getDenominator());
-        assertEquals(Integer.valueOf(0), response.getWarning().getHandlingEfficiency().getNumerator());
-        assertEquals("EMPTY", response.getWarning().getHandlingEfficiency().getState());
-        assertEquals("NO_ELIGIBLE_WORK_ORDERS",
-                response.getWarning().getHandlingEfficiency().getReason());
+        CockpitOverviewWarningSettingsResponse.Efficiency efficiency =
+                response.getWarning().getHandlingEfficiency();
+        assertEquals(1, efficiency.getNumerator());
+        assertEquals(6, efficiency.getDenominator());
+        assertEquals("0.1667", efficiency.getValue().toPlainString());
+        assertEquals("AVAILABLE", efficiency.getState());
+    }
+
+    /** 等级 / 行业 / 片区分布都逐桶自洽,且未分区是显式桶。 */
+    @Test
+    void warningLevelIndustryAndDistrictDistributionsAreSelfConsistent() {
+        when(adapter.load()).thenReturn(warningFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-warning", "WARNING", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        // 1–4 全铺 + 一条等级缺失进未知桶。
+        assertEquals(5, warning.getLevelDistribution().size());
+        assertEquals(2, count(warning.getLevelDistribution(), "2"));
+        assertEquals(3, count(warning.getLevelDistribution(), "3"));
+        assertEquals(1, count(warning.getLevelDistribution(), "UNKNOWN"));
+        assertEquals(warning.getTotal(), sum(warning.getLevelDistribution()));
+
+        assertEquals(2, count(warning.getIndustryDistribution(), "GAS"));
+        assertEquals(3, count(warning.getIndustryDistribution(), "WATER_SUPPLY"));
+        assertEquals(1, count(warning.getIndustryDistribution(), "UNKNOWN"));
+        assertEquals(warning.getTotal(), sum(warning.getIndustryDistribution()));
+
+        assertEquals(2, count(warning.getDistrictDistribution(), "0"));
+        assertEquals(3, count(warning.getDistrictDistribution(), "1"));
+        // 未分区恒有显式桶(设备缺失是合法结果,不是缺数据)。
+        assertEquals(1, count(warning.getDistrictDistribution(), "UNASSIGNED"));
+        assertEquals(warning.getTotal(), sum(warning.getDistrictDistribution()));
+    }
+
+    /** 报警族保留工单口径(维护者裁定:报警靠工单闭环),并并存报警自身生命周期。 */
+    @Test
+    void alarmFamilyKeepsWorkOrderVocabularyAndAddsItsOwnLifecycle() {
+        when(adapter.load()).thenReturn(alarmFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-alarm", "ALARM", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics alarm = response.getAlarm();
+        // 工单口径仍在:分母=状态 0–6 工单数(7 张,排除状态 7),分子=状态 5(1 张)。
+        assertEquals(1, alarm.getHandlingEfficiency().getNumerator());
+        assertEquals(7, alarm.getHandlingEfficiency().getDenominator());
+        assertEquals("0.1429", alarm.getHandlingEfficiency().getValue().toPlainString());
+        assertEquals(8, alarm.getWorkOrderStates().size());
+        assertEquals(1, alarm.getWorkOrderStates().get(5));
+        // 报警自身生命周期:alarm_status 0/1。
+        assertEquals(1, count(alarm.getStatusDistribution(), "HANDLED"));
+        assertEquals(2, count(alarm.getStatusDistribution(), "UNHANDLED"));
+        assertEquals(alarm.getTotal(), sum(alarm.getStatusDistribution()));
+    }
+
+    /** 报警族工单口径分列:未回联 / 脏码 / 已取消 各自如实计数,不互相冒充。 */
+    @Test
+    void alarmWorkOrderBucketsSeparateUnlinkedInvalidAndCancelled() {
+        when(adapter.load()).thenReturn(alarmFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-alarm", "ALARM", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics alarm = response.getAlarm();
+        assertEquals(1, alarm.getUnlinkedEventCount());
+        assertEquals(1, alarm.getInvalidWorkOrderStateCount());
+        assertEquals(1, alarm.getCancelledCount());
+        assertEquals(1, alarm.getPending());
     }
 
+    /** 工单表整体不可用时,预警族仍如实出数(这是本票要消除的故障面)。 */
     @Test
-    void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
+    void warningFamilyStaysAvailableWhenNoWorkOrderFactsExistAtAll() {
+        when(adapter.load()).thenReturn(warningFixtureWithoutWorkOrders());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-warning", "WARNING", null, null, null);
+
+        assertEquals(2, response.getWarning().getTotal());
+        assertEquals("READY", response.getWarning().getState());
+        assertEquals(1, count(response.getWarning().getStatusDistribution(), "CLOSED"));
+        // 报警族没有工单事实 → 处置率显式「不适用」,不是 0%。
+        assertEquals("EMPTY", response.getAlarm().getHandlingEfficiency().getState());
+        assertEquals("NO_ELIGIBLE_WORK_ORDERS", response.getAlarm().getHandlingEfficiency().getReason());
+    }
+
+    /** 空态:两族都显式 EMPTY,预警族处置率不适用。 */
+    @Test
+    void emptyFamilyUsesExplicitEmptyState() {
         when(adapter.load()).thenReturn(emptyFixture());
 
         CockpitOverviewWarningSettingsResponse response =
@@ -101,14 +181,13 @@ class CockpitOverviewWarningSettingsServiceTest {
 
         assertEquals("EMPTY", response.getAlarm().getState());
         assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getTotal());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandled());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getPending());
-        assertEquals("0.0000", response.getAlarm().getHandlingEfficiency().getValue().toPlainString());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getNumerator());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getDenominator());
-        assertEquals("EMPTY", response.getAlarm().getHandlingEfficiency().getState());
-        assertEquals("NO_ELIGIBLE_WORK_ORDERS", response.getAlarm().getHandlingEfficiency().getReason());
+        assertEquals(0, response.getAlarm().getTotal());
+        assertEquals("EMPTY", response.getWarning().getState());
+        assertEquals("EMPTY", response.getWarning().getHandlingEfficiency().getState());
+        assertEquals("NO_WARNINGS", response.getWarning().getHandlingEfficiency().getReason());
+        // 空态下分布仍全铺已知桶,只是计数为 0。
+        assertEquals(6, response.getWarning().getStatusDistribution().size());
+        assertEquals(0, sum(response.getWarning().getStatusDistribution()));
     }
 
     @Test
@@ -120,52 +199,101 @@ class CockpitOverviewWarningSettingsServiceTest {
 
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
-        assertTrue(response.getUpdatedAt() == null);
+        assertNull(response.getUpdatedAt());
         assertEquals("EMPTY", response.getWarning().getState());
     }
 
-    private static CockpitOverviewWarningSettingsSnapshot eightStateFixture() {
+    /** 趋势窗口跟随 `range`:窗口外桶被裁掉,窗口内保留。 */
+    @Test
+    void trendWindowFollowsTheDeclaredRange() {
+        when(adapter.load()).thenReturn(warningFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-range", "WARNING", "LAST_7_DAYS", null, null);
+
+        assertEquals("LAST_7_DAYS", response.getRange().getType());
+        List<String> buckets = response.getWarning().getTrend().stream()
+                .map(CockpitOverviewWarningSettingsResponse.TrendPoint::getBucket)
+                .collect(Collectors.toList());
+        // 固定时钟 2026-09-02T10:00+08:00 起算近 7 日 → 只保留 2026-09-01。
+        assertTrue(buckets.contains("2026-09-01"));
+        assertTrue(!buckets.contains("2026-08-01"));
+    }
+
+    private static int count(List<CockpitOverviewWarningSettingsResponse.Bucket> buckets, String code) {
+        return buckets.stream()
+                .filter(bucket -> code.equals(bucket.getCode()))
+                .mapToInt(CockpitOverviewWarningSettingsResponse.Bucket::getCount)
+                .findFirst().orElse(-1);
+    }
+
+    private static int sum(List<CockpitOverviewWarningSettingsResponse.Bucket> buckets) {
+        return buckets.stream().mapToInt(CockpitOverviewWarningSettingsResponse.Bucket::getCount).sum();
+    }
+
+    /** 预警族六态各一条(CLOSED 两条)+ 等级/行业/片区维度齐全。 */
+    private static CockpitOverviewWarningSettingsSnapshot warningFixture() {
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
-                        event("WARNING", "WRN-000", "WO-000", 0),
-                        event("WARNING", "WRN-001", "WO-001", 1),
-                        event("WARNING", "WRN-002", "WO-002", 2),
-                        event("WARNING", "WRN-003", "WO-003", 3),
-                        event("WARNING", "WRN-004", "WO-004", 4),
-                        event("WARNING", "WRN-005", "WO-005", 5),
-                        event("WARNING", "WRN-006", "WO-006", 6),
-                        event("WARNING", "WRN-007", "WO-007", 7),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-008", "DEV-008", null, null),
-                        event("WARNING", "WRN-009", "WO-009", 9),
-                        event("ALARM", "ALM-001", "WO-101", 3),
-                        event("ALARM", "ALM-002", "WO-102", 5)),
-                Collections.emptyList(),
-                // 全范围工单:状态 0–7 各一张 → 分母 7(排除状态 7)、分子 1(状态 5)。
+                        warning("W-DRAFT", "DRAFT", 2, "GAS", 0),
+                        warning("W-PENDING", "PENDING", 3, "WATER_SUPPLY", 1),
+                        warning("W-RECEIVED", "RECEIVED", 3, "WATER_SUPPLY", 1),
+                        warning("W-PROCESSING", "PROCESSING", null, null, null),
+                        warning("W-HANDLED", "HANDLED", 3, "WATER_SUPPLY", 1),
+                        warning("W-CLOSED", "CLOSED", 2, "GAS", 0)),
                 Arrays.asList(
-                        order(0), order(1), order(2), order(3),
-                        order(4), order(5), order(6), order(7)));
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-09-01", 2, 1),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-08-01", 1, 0)),
+                Collections.emptyList());
     }
 
-    /** 只有已取消工单 → 分母 0,用于验证「不适用」而非 0%。 */
-    private static CockpitOverviewWarningSettingsSnapshot cancelledHeavyFixture() {
+    /** 预警族有数据、工单事实为空:验证预警不再依赖工单。 */
+    private static CockpitOverviewWarningSettingsSnapshot warningFixtureWithoutWorkOrders() {
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
-                Collections.singletonList(event("WARNING", "WRN-007", "WO-007", 7)),
+                Arrays.asList(
+                        warning("W-1", "CLOSED", 2, "GAS", 0),
+                        warning("W-2", "PROCESSING", 3, "GAS", 0)),
                 Collections.emptyList(),
-                Arrays.asList(order(7), order(7)));
+                Collections.emptyList());
     }
 
-    private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder order(Integer state) {
-        return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
-                state, "2026-09-02T09:00:00+08:00");
+    /** 报警族:工单口径(状态 0–7 各一张)+ 自身生命周期(1 已处理 / 2 未处理 / 1 未回联 / 1 脏码)。 */
+    private static CockpitOverviewWarningSettingsSnapshot alarmFixture() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        alarm("A-1", "HANDLED", 5, 3, "GAS", 0),
+                        alarm("A-2", "UNHANDLED", 3, 3, "WATER_SUPPLY", 1),
+                        alarm("A-3", "UNHANDLED", 7, 2, "GAS", 0),
+                        alarm("A-4", null, null, 2, "GAS", 0),
+                        alarm("A-5", null, 9, 2, "GAS", 0)),
+                Collections.emptyList(),
+                Arrays.asList(
+                        order(0), order(1), order(2), order(3),
+                        order(4), order(5), order(6), order(7)));
     }
 
-    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
-            String family, String eventCode, String workOrderCode, Integer state) {
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent warning(
+            String code, String status, Integer level, String industry, Integer district) {
         return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                family, eventCode, "DEV-" + eventCode, workOrderCode, state);
+                "WARNING", code, "DEV-" + code, null, null, status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent alarm(
+            String code, String status, Integer workOrderState, Integer level,
+            String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "ALARM", code, "DEV-" + code, "WO-" + code, workOrderState,
+                status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder order(Integer state) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
+                state, "2026-09-02T09:00:00+08:00");
     }
 
     private static CockpitOverviewWarningSettingsSnapshot emptyFixture() {

+ 133 - 84
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewWarningSettingsAdapter.java

@@ -3,10 +3,16 @@ package com.zksy.cockpit.overview;
 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.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
 import org.springframework.context.annotation.Primary;
@@ -28,10 +34,16 @@ import java.util.stream.Collectors;
 /**
  * 总体概览-预警设置 真实数据适配器(BT-5)。
  *
- * <p>报警事件来自 {@code alarm_data}(经 {@code work_order_id → work_order.order_id} 关联工单);
- * 预警事件来自 {@code early_warning},工单经
- * {@code alarm_warning_rel.warning_id → alarm_data.work_order_id → work_order} 桥接(README 冻结关联)。
- * 趋势桶按业务时区日期({@code yyyy-MM-dd})聚合,已处理 = 关联工单已结案(状态 5)。
+ * <p><b>口径(维护者 2026-10-14 裁定)</b>:预警不走工单流程,故预警事件**不再经工单桥接**,
+ * 只带 {@code early_warning} 自身生命周期({@code status} / {@code warning_level});
+ * 报警保留工单回联(报警靠工单闭环),并额外带 {@code alarm_status} / {@code alarm_level}。
+ *
+ * <p>两族都沿设备解析**片区**({@code equipment_base.district})与**行业**
+ * ({@code equipment_type} 树上溯根),解析不出时如实留空、不冒充 ——
+ * 这两项服务层拿不到设备台账,必须在此带入快照。
+ *
+ * <p>趋势桶按业务时区日期({@code yyyy-MM-dd})聚合。{@code handled} 的含义**按族不同**:
+ * 预警=该桶内已解除({@code CLOSED});报警=该桶内关联工单已结案(状态 5)。
  * {@code updatedAt} 取事件与工单的最新时间。
  */
 @Component
@@ -43,21 +55,28 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
     private static final DateTimeFormatter BUCKET_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final int WORK_ORDER_HANDLED = 5;
+    private static final String WARNING_CLOSED = "CLOSED";
 
     private final EarlyWarningService earlyWarningService;
     private final AlarmDataService alarmDataService;
     private final AlarmWarningRelMapper alarmWarningRelMapper;
     private final WorkOrderService workOrderService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
 
     public BusinessCockpitOverviewWarningSettingsAdapter(
             EarlyWarningService earlyWarningService,
             AlarmDataService alarmDataService,
             AlarmWarningRelMapper alarmWarningRelMapper,
-            WorkOrderService workOrderService) {
+            WorkOrderService workOrderService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService) {
         this.earlyWarningService = earlyWarningService;
         this.alarmDataService = alarmDataService;
         this.alarmWarningRelMapper = alarmWarningRelMapper;
         this.workOrderService = workOrderService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
     }
 
     @Override
@@ -74,23 +93,9 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
         }
 
-        Map<String, List<String>> warningNosByAlarmId = new HashMap<>();
-        Map<String, String> warningNoById = new HashMap<>();
-        for (EarlyWarning warning : warnings) {
-            if (warning == null || isBlank(warning.getWarningId()) || isBlank(warning.getWarningNo())) {
-                continue;
-            }
-            warningNoById.put(warning.getWarningId(), warning.getWarningNo().trim());
-        }
-        for (AlarmWarningRel relation : relations) {
-            if (relation == null || isBlank(relation.getAlarmId())) {
-                continue;
-            }
-            String warningNo = warningNoById.get(relation.getWarningId());
-            if (warningNo != null) {
-                warningNosByAlarmId.computeIfAbsent(relation.getAlarmId(), key -> new ArrayList<>()).add(warningNo);
-            }
-        }
+        // 设备与类型索引:片区/行业只能沿设备解析,服务层拿不到台账。
+        Map<String, EquipmentBase> deviceByCode = deviceIndex();
+        Map<Integer, EquipmentType> typesById = typeIndex();
 
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events = new ArrayList<>();
         LocalDateTime updatedAt = null;
@@ -111,7 +116,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
                     alarm.getId().trim(),
                     trimToNull(alarm.getDeviceCode()),
                     order == null ? null : order.getOrderNo(),
-                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus()));
+                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus(),
+                    alarmStatus(alarm.getAlarmStatus()),
+                    alarm.getAlarmLevel(),
+                    districtCode(alarm.getDeviceCode(), deviceByCode),
+                    industryCode(alarm.getDeviceCode(), deviceByCode, typesById)));
         }
 
         for (EarlyWarning warning : warnings) {
@@ -120,48 +129,33 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             updatedAt = latest(updatedAt, warning.getUpdateTime());
             updatedAt = latest(updatedAt, warning.getPublishTime());
-            List<String> linkedAlarmIds = new ArrayList<>();
-            for (AlarmWarningRel relation : relations) {
-                if (relation != null && warning.getWarningId() != null
-                        && warning.getWarningId().equals(relation.getWarningId())) {
-                    linkedAlarmIds.add(relation.getAlarmId());
-                }
-            }
-            WorkOrder order = null;
-            for (String alarmId : linkedAlarmIds) {
-                AlarmData alarm = alarms.stream()
-                        .filter(candidate -> candidate != null && alarmId != null && alarmId.equals(candidate.getId()))
-                        .findFirst().orElse(null);
-                if (alarm != null && alarm.getWorkOrderId() != null) {
-                    order = orderByOrderId.get(alarm.getWorkOrderId());
-                    if (order != null) {
-                        break;
-                    }
-                }
-            }
-            if (order != null) {
-                updatedAt = latest(updatedAt, toLocal(order.getUpdateTime()));
-                updatedAt = latest(updatedAt, toLocal(order.getCreateTime()));
-            }
+            // 预警不走工单流程(维护者 2026-10-14 裁定):不再桥接工单,
+            // 只带自身生命周期与维度;趋势 handled 也改按预警自身 CLOSED 判定。
             events.add(new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
                     "WARNING",
                     warning.getWarningNo().trim(),
                     trimToNull(warning.getDeviceCode()),
-                    order == null ? null : order.getOrderNo(),
-                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus()));
+                    null,
+                    null,
+                    trimToNull(warning.getStatus()),
+                    warningLevel(warning.getWarningLevel()),
+                    districtCode(warning.getDeviceCode(), deviceByCode),
+                    industryCode(warning.getDeviceCode(), deviceByCode, typesById)));
         }
 
         List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints = new ArrayList<>();
-        addBucket(trendPoints, "WARNING", bucketFor(warnings, alarms, warningNosByAlarmId, orderByOrderId));
-        addBucket(trendPoints, "ALARM", bucketFor(alarms, orderByOrderId));
+        addBuckets(trendPoints, "WARNING", warningBuckets(warnings));
+        addBuckets(trendPoints, "ALARM", alarmBuckets(alarms, orderByOrderId));
 
         return new CockpitOverviewWarningSettingsSnapshot(
                 offsetTime(updatedAt), events, trendPoints, scopeWorkOrders(orders));
     }
 
     /**
-     * 范围内**全部工单**(决策票 06):处置率分母要的是工单事实,不要求事件回联。
+     * 范围内**全部工单**(决策票 06):**报警族**处置率分母要的是工单事实,不要求事件回联。
      * 状态码原样透出(是否计入分母由服务层的 0–6 口径判定)。
+     *
+     * <p>维护者 2026-10-14 裁定后,本读数只服务报警族。
      */
     private static List<CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder> scopeWorkOrders(
             List<WorkOrder> orders) {
@@ -177,11 +171,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         return result;
     }
 
-    private Map<String, Counter> bucketFor(
-            List<EarlyWarning> warnings,
-            List<AlarmData> alarms,
-            Map<String, List<String>> warningNosByAlarmId,
-            Map<Long, WorkOrder> orderByOrderId) {
+    /**
+     * 预警趋势桶:按发布时间分桶,{@code handled}=该桶内**已解除**的预警数
+     * (预警自身生命周期,不依赖工单)。
+     */
+    private Map<String, Counter> warningBuckets(List<EarlyWarning> warnings) {
         Map<String, Counter> buckets = new LinkedHashMap<>();
         for (EarlyWarning warning : warnings) {
             if (warning == null || isBlank(warning.getWarningNo())) {
@@ -193,14 +187,15 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
-            if (isHandledWarning(warning, warningNosByAlarmId, alarms, orderByOrderId)) {
+            if (WARNING_CLOSED.equals(normalize(warning.getStatus()))) {
                 counter.handled++;
             }
         }
         return buckets;
     }
 
-    private Map<String, Counter> bucketFor(List<AlarmData> alarms, Map<Long, WorkOrder> orderByOrderId) {
+    /** 报警趋势桶:按更新时间分桶,{@code handled}=该桶内关联工单已结案的报警数(工单口径)。 */
+    private Map<String, Counter> alarmBuckets(List<AlarmData> alarms, Map<Long, WorkOrder> orderByOrderId) {
         Map<String, Counter> buckets = new LinkedHashMap<>();
         for (AlarmData alarm : alarms) {
             if (alarm == null || isBlank(alarm.getId())) {
@@ -213,37 +208,14 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
             WorkOrder order = alarm.getWorkOrderId() == null ? null : orderByOrderId.get(alarm.getWorkOrderId());
-            if (order != null && WORK_ORDER_HANDLED == order.getOrderStatus()) {
+            if (order != null && Integer.valueOf(WORK_ORDER_HANDLED).equals(order.getOrderStatus())) {
                 counter.handled++;
             }
         }
         return buckets;
     }
 
-    private boolean isHandledWarning(
-            EarlyWarning warning,
-            Map<String, List<String>> warningNosByAlarmId,
-            List<AlarmData> alarms,
-            Map<Long, WorkOrder> orderByOrderId) {
-        if (warning.getWarningId() == null) {
-            return false;
-        }
-        for (AlarmData alarm : alarms) {
-            if (alarm == null || alarm.getWorkOrderId() == null) {
-                continue;
-            }
-            List<String> linked = warningNosByAlarmId.get(alarm.getId());
-            if (linked != null && linked.contains(warning.getWarningNo().trim())) {
-                WorkOrder order = orderByOrderId.get(alarm.getWorkOrderId());
-                if (order != null && WORK_ORDER_HANDLED == order.getOrderStatus()) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    private void addBucket(
+    private void addBuckets(
             List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints,
             String family,
             Map<String, Counter> buckets) {
@@ -253,6 +225,83 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         }
     }
 
+    /** 报警自身生命周期:{@code alarm_status} 0 未处理 / 1 已处理;其余回 {@code null}(服务层进未知桶)。 */
+    private static String alarmStatus(Integer status) {
+        if (Integer.valueOf(0).equals(status)) {
+            return "UNHANDLED";
+        }
+        if (Integer.valueOf(1).equals(status)) {
+            return "HANDLED";
+        }
+        return null;
+    }
+
+    /** 预警等级:{@code warning_level} 是字符串,仅 1–4 计算编码有效;其余回 {@code null}。 */
+    private static Integer warningLevel(String level) {
+        if (level == null) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(level.trim());
+            return resolved >= 1 && resolved <= 4 ? resolved : null;
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    /**
+     * 事件片区:沿事件设备解析到 {@code equipment_base.district}(片区权威来源),
+     * 再经 {@link CockpitDistrict#codeOf} 归一;设备缺失或片区未识别时如实回 {@code null}(=未分区)。
+     */
+    private static Integer districtCode(String deviceCode, Map<String, EquipmentBase> deviceByCode) {
+        EquipmentBase device = device(deviceCode, deviceByCode);
+        return device == null ? null : CockpitDistrict.codeOf(device.getDistrict());
+    }
+
+    /**
+     * 事件行业:沿设备类型树上溯到根再映射({@link CockpitTypeIndustry})。
+     * 设备缺失、类型缺失或根不在取值域内时如实回 {@code null}(服务层进未知桶),不按片区或名称冒充。
+     */
+    private static String industryCode(
+            String deviceCode,
+            Map<String, EquipmentBase> deviceByCode,
+            Map<Integer, EquipmentType> typesById) {
+        EquipmentBase device = device(deviceCode, deviceByCode);
+        if (device == null || device.getEquipmentTypeId() == null) {
+            return null;
+        }
+        return CockpitTypeIndustry.industryOf(typesById.get(device.getEquipmentTypeId()), typesById);
+    }
+
+    private static EquipmentBase device(String deviceCode, Map<String, EquipmentBase> deviceByCode) {
+        String normalized = trimToNull(deviceCode);
+        return normalized == null ? null : deviceByCode.get(normalized);
+    }
+
+    private Map<String, EquipmentBase> deviceIndex() {
+        Map<String, EquipmentBase> result = new LinkedHashMap<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device != null && !isBlank(device.getEquipmentCode())) {
+                result.put(device.getEquipmentCode().trim(), device);
+            }
+        }
+        return result;
+    }
+
+    private Map<Integer, EquipmentType> typeIndex() {
+        Map<Integer, EquipmentType> result = new LinkedHashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static String normalize(String value) {
+        return value == null ? null : value.trim().toUpperCase();
+    }
+
     private static String bucket(LocalDateTime time) {
         return BUCKET_FORMATTER.format(time.atZone(BUSINESS_ZONE).toLocalDate());
     }
@@ -299,4 +348,4 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         private int total;
         private int handled;
     }
-}
+}

+ 73 - 15
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsResponse.java

@@ -99,18 +99,42 @@ public class CockpitOverviewWarningSettingsResponse {
         public String getServerUpdatedAt() { return serverUpdatedAt; }
     }
 
+    /**
+     * 一族(预警或报警)的统计。
+     *
+     * <p><b>预警族</b>(维护者 2026-10-14 裁定:预警不走工单流程)只给**预警自身生命周期**读数:
+     * {@code total}、{@code statusDistribution}({@code WarningWorkflow} 六态)、{@code levelDistribution}、
+     * {@code industryDistribution}、{@code districtDistribution}、{@code handlingEfficiency}
+     * (已解除 ÷ 预警总数,非工单口径)、{@code trend}(按发布时间分桶)。
+     * 工单口径字段({@code handled}/{@code pending}/{@code cancelledCount}/{@code unlinkedEventCount}/
+     * {@code invalidWorkOrderStateCount}/{@code workOrderStates})一律回 {@code null} ——
+     * 「不适用」与「真的是 0」必须分得开,故用包装类型而非 0 冒充。
+     *
+     * <p><b>报警族</b>(维护者裁定:报警生命周期与工单关联性强,报警靠工单闭环)**保留工单口径**
+     * 全部字段与语义({@code handlingEfficiency} 仍是工单口径处置率),同时**并存**报警自身生命周期的
+     * {@code statusDistribution}({@code alarm_status})、{@code levelDistribution}、
+     * {@code industryDistribution}、{@code districtDistribution}。两者并存,不互相冒充。
+     *
+     * <p>除 {@code trend} 外,全部读数取**整个快照**(不随 {@code range} 收缩),
+     * 故 {@code sum(statusDistribution)} == {@code total} 恒成立,与既有 {@code total} 的
+     * 「当前」语义一致;只有趋势桶跟随声明窗口。
+     */
     public static class FamilyMetrics {
         private final String eventFamily;
         private final String state;
         private final String stateReason;
         private final int total;
-        private final int handled;
-        private final int pending;
-        private final int cancelledCount;
-        private final int unlinkedEventCount;
-        private final int invalidWorkOrderStateCount;
+        private final Integer handled;
+        private final Integer pending;
+        private final Integer cancelledCount;
+        private final Integer unlinkedEventCount;
+        private final Integer invalidWorkOrderStateCount;
         private final Efficiency handlingEfficiency;
         private final Map<Integer, Integer> workOrderStates;
+        private final List<Bucket> statusDistribution;
+        private final List<Bucket> levelDistribution;
+        private final List<Bucket> industryDistribution;
+        private final List<Bucket> districtDistribution;
         private final List<TrendPoint> trend;
 
         public FamilyMetrics(
@@ -118,13 +142,17 @@ public class CockpitOverviewWarningSettingsResponse {
                 String state,
                 String stateReason,
                 int total,
-                int handled,
-                int pending,
-                int cancelledCount,
-                int unlinkedEventCount,
-                int invalidWorkOrderStateCount,
+                Integer handled,
+                Integer pending,
+                Integer cancelledCount,
+                Integer unlinkedEventCount,
+                Integer invalidWorkOrderStateCount,
                 Efficiency handlingEfficiency,
                 Map<Integer, Integer> workOrderStates,
+                List<Bucket> statusDistribution,
+                List<Bucket> levelDistribution,
+                List<Bucket> industryDistribution,
+                List<Bucket> districtDistribution,
                 List<TrendPoint> trend) {
             this.eventFamily = eventFamily;
             this.state = state;
@@ -137,6 +165,10 @@ public class CockpitOverviewWarningSettingsResponse {
             this.invalidWorkOrderStateCount = invalidWorkOrderStateCount;
             this.handlingEfficiency = handlingEfficiency;
             this.workOrderStates = workOrderStates;
+            this.statusDistribution = statusDistribution;
+            this.levelDistribution = levelDistribution;
+            this.industryDistribution = industryDistribution;
+            this.districtDistribution = districtDistribution;
             this.trend = trend;
         }
 
@@ -144,16 +176,42 @@ public class CockpitOverviewWarningSettingsResponse {
         public String getState() { return state; }
         public String getStateReason() { return stateReason; }
         public int getTotal() { return total; }
-        public int getHandled() { return handled; }
-        public int getPending() { return pending; }
-        public int getCancelledCount() { return cancelledCount; }
-        public int getUnlinkedEventCount() { return unlinkedEventCount; }
-        public int getInvalidWorkOrderStateCount() { return invalidWorkOrderStateCount; }
+        public Integer getHandled() { return handled; }
+        public Integer getPending() { return pending; }
+        public Integer getCancelledCount() { return cancelledCount; }
+        public Integer getUnlinkedEventCount() { return unlinkedEventCount; }
+        public Integer getInvalidWorkOrderStateCount() { return invalidWorkOrderStateCount; }
         public Efficiency getHandlingEfficiency() { return handlingEfficiency; }
         public Map<Integer, Integer> getWorkOrderStates() { return workOrderStates; }
+        public List<Bucket> getStatusDistribution() { return statusDistribution; }
+        public List<Bucket> getLevelDistribution() { return levelDistribution; }
+        public List<Bucket> getIndustryDistribution() { return industryDistribution; }
+        public List<Bucket> getDistrictDistribution() { return districtDistribution; }
         public List<TrendPoint> getTrend() { return trend; }
     }
 
+    /**
+     * 一个分布桶。{@code code} 是机器键;{@code label} 是展示词。
+     *
+     * <p>「未知」类桶(状态/等级无法识别、行业推不出、片区未分区)也如实入桶,
+     * 因此 {@code sum(桶计数)} 恒等于该族 {@code total},不静默丢数据。
+     */
+    public static class Bucket {
+        private final String code;
+        private final String label;
+        private final int count;
+
+        public Bucket(String code, String label, int count) {
+            this.code = code;
+            this.label = label;
+            this.count = count;
+        }
+
+        public String getCode() { return code; }
+        public String getLabel() { return label; }
+        public int getCount() { return count; }
+    }
+
     public static class Efficiency {
         private final int numerator;
         private final int denominator;

+ 281 - 42
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java

@@ -1,7 +1,9 @@
 package com.zksy.cockpit.overview;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.workorder.CockpitWorkOrderState;
 import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -21,8 +23,25 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
+/**
+ * 总体概览-预警设置。
+ *
+ * <p><b>口径(维护者 2026-10-14 裁定)</b>:
+ * <ul>
+ *   <li><b>预警不走工单流程</b>:预警族只给预警自身生命周期读数
+ *       (状态分布取 {@link WarningWorkflow} 六态、等级分布、行业/片区分布、
+ *       已解除÷总数、按发布时间分桶的趋势)。其工单口径字段一律 {@code null}。</li>
+ *   <li><b>报警靠工单闭环</b>:报警族保留工单口径({@code handled}/{@code pending}/
+ *       {@code cancelledCount}/{@code workOrderStates}/工单口径处置率),**同时**并存
+ *       报警自身生命周期分布。</li>
+ * </ul>
+ *
+ * <p>本端点因此**不再要求工单事实对预警族可用**:预警族读的是 {@code early_warning} 自身,
+ * 工单表不可用时预警族仍如实出数。
+ */
 @Service
 public class CockpitOverviewWarningSettingsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
@@ -35,8 +54,33 @@ public class CockpitOverviewWarningSettingsService {
                     .map(CockpitWorkOrderState::getCode)
                     .collect(Collectors.toList());
     private static final int HANDLED_STATE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
+    private static final int CANCELLED_STATE = CockpitWorkOrderStateDictionary.CANCELLED_RATE_CODE;
     private static final DateTimeFormatter BUCKET_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
 
+    /** 预警自身生命周期的展示顺序({@link WarningWorkflow} 主线),不重排、不合并。 */
+    private static final List<String> WARNING_STATUSES = Collections.unmodifiableList(Arrays.asList(
+            WarningWorkflow.DRAFT,
+            WarningWorkflow.PENDING,
+            WarningWorkflow.RECEIVED,
+            WarningWorkflow.PROCESSING,
+            WarningWorkflow.HANDLED,
+            WarningWorkflow.CLOSED));
+
+    /** 报警自身生命周期:{@code alarm_data.alarm_status} 0/1。 */
+    private static final String ALARM_UNHANDLED = "UNHANDLED";
+    private static final String ALARM_HANDLED = "HANDLED";
+    private static final List<String> ALARM_STATUSES =
+            Collections.unmodifiableList(Arrays.asList(ALARM_UNHANDLED, ALARM_HANDLED));
+
+    private static final List<Integer> LEVELS = Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4));
+
+    private static final List<String> INDUSTRIES = Collections.unmodifiableList(
+            Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE"));
+
+    /** 未知类桶的机器键与展示词:无法识别时如实入桶,不静默丢数据。 */
+    private static final String UNKNOWN_CODE = "UNKNOWN";
+    private static final String UNKNOWN_LABEL = "未知";
+
     private final CockpitOverviewWarningSettingsAdapter adapter;
     private final Clock clock;
 
@@ -61,7 +105,6 @@ public class CockpitOverviewWarningSettingsService {
         String freshnessStatus = hasSnapshot ? "CURRENT" : "EMPTY";
         String freshnessReason = hasSnapshot ? "READ_ONLY_AGGREGATE" : "NO_SNAPSHOT";
 
-        ScopeRate scopeRate = scopeRate(snapshot, window);
         return new CockpitOverviewWarningSettingsResponse(
                 requestId,
                 "v1",
@@ -79,8 +122,8 @@ public class CockpitOverviewWarningSettingsService {
                         freshnessReason,
                         updatedAt,
                         businessTime()),
-                familyMetrics(snapshot, "WARNING", window, scopeRate),
-                familyMetrics(snapshot, "ALARM", window, scopeRate),
+                warningFamily(events(snapshot, "WARNING"), snapshot, window),
+                alarmFamily(events(snapshot, "ALARM"), snapshot, window),
                 mapFilter(selectedMode));
     }
 
@@ -157,59 +200,116 @@ public class CockpitOverviewWarningSettingsService {
         }
     }
 
-    private CockpitOverviewWarningSettingsResponse.FamilyMetrics familyMetrics(
-            CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily,
-            Window window,
-            ScopeRate scopeRate) {
-        List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
-                snapshot == null || snapshot.getEvents() == null
-                        ? Collections.emptyList()
-                        : snapshot.getEvents();
+    private static List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String eventFamily) {
+        if (snapshot == null || snapshot.getEvents() == null) {
+            return Collections.emptyList();
+        }
+        return snapshot.getEvents().stream()
+                .filter(event -> event != null && eventFamily.equals(event.getEventFamily()))
+                .collect(Collectors.toList());
+    }
 
-        Map<Integer, Integer> workOrderStates = new LinkedHashMap<>();
-        for (Integer state : WORK_ORDER_STATES) {
-            workOrderStates.put(state, 0);
+    /**
+     * 预警族:**只**给预警自身生命周期读数。工单口径字段全部 {@code null}。
+     *
+     * <p>分布取**整个快照**(不随 `range` 收缩),与既有 {@code total} 的「当前」语义一致,
+     * 故 `sum(statusDistribution) == total` 恒成立;只有趋势桶跟随声明窗口。
+     */
+    private CockpitOverviewWarningSettingsResponse.FamilyMetrics warningFamily(
+            List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events,
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            Window window) {
+        int total = events.size();
+        int closed = 0;
+        Map<String, Integer> statusCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> levelCounts = new LinkedHashMap<>();
+        Map<String, Integer> industryCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
+
+        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : events) {
+            String status = normalizeWarningStatus(event.getStatus());
+            statusCounts.merge(status == null ? UNKNOWN_CODE : status, 1, Integer::sum);
+            if (WarningWorkflow.CLOSED.equals(status)) {
+                closed++;
+            }
+            levelCounts.merge(normalizeLevel(event.getLevel()), 1, Integer::sum);
+            industryCounts.merge(
+                    event.getIndustryCode() == null ? UNKNOWN_CODE : event.getIndustryCode(), 1, Integer::sum);
+            districtCounts.merge(event.getDistrictCode(), 1, Integer::sum);
         }
 
-        int total = 0;
+        return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
+                "WARNING",
+                total == 0 ? "EMPTY" : "READY",
+                total == 0 ? "NO_EVENTS" : "WARNINGS_RESOLVED",
+                total,
+                // 工单口径字段:预警不走工单流程 → 显式 null(不适用),不用 0 冒充。
+                null,
+                null,
+                null,
+                null,
+                null,
+                selfLifecycleEfficiency(closed, total),
+                null,
+                statusBuckets(statusCounts, WARNING_STATUSES,
+                        CockpitOverviewWarningSettingsService::warningStatusLabel),
+                levelBuckets(levelCounts),
+                industryBuckets(industryCounts),
+                districtBuckets(districtCounts),
+                trend(snapshot, "WARNING", window));
+    }
+
+    /**
+     * 报警族:保留工单口径(维护者裁定:报警靠工单闭环),并**并存**报警自身生命周期分布。
+     *
+     * <p>{@code handled} 是报警自身生命周期({@code alarm_status=1}),
+     * {@code handlingEfficiency} 仍是**工单口径**处置率(状态 5 ÷ 状态 0–6)——
+     * 两者刻意不同源,各自命名清楚,不互相冒充。
+     */
+    private CockpitOverviewWarningSettingsResponse.FamilyMetrics alarmFamily(
+            List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events,
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            Window window) {
+        ScopeRate scopeRate = scopeRate(snapshot, window);
+
+        int total = events.size();
         int handled = 0;
         int pending = 0;
         int cancelledCount = 0;
         int unlinkedEventCount = 0;
         int invalidWorkOrderStateCount = 0;
-        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : sourceEvents) {
-            if (event == null || !eventFamily.equals(event.getEventFamily())) {
-                continue;
+        Map<String, Integer> statusCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> levelCounts = new LinkedHashMap<>();
+        Map<String, Integer> industryCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
+
+        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : events) {
+            String status = normalizeAlarmStatus(event.getStatus());
+            statusCounts.merge(status == null ? UNKNOWN_CODE : status, 1, Integer::sum);
+            if (ALARM_HANDLED.equals(status)) {
+                handled++;
             }
+            levelCounts.merge(normalizeLevel(event.getLevel()), 1, Integer::sum);
+            industryCounts.merge(
+                    event.getIndustryCode() == null ? UNKNOWN_CODE : event.getIndustryCode(), 1, Integer::sum);
+            districtCounts.merge(event.getDistrictCode(), 1, Integer::sum);
+
+            // 工单口径(与改造前一致):报警靠工单闭环,未回联/脏码如实分列。
             Integer state = event.getWorkOrderState();
             if (state == null) {
-                total++;
                 unlinkedEventCount++;
-                continue;
-            }
-            if (!WORK_ORDER_STATES.contains(state)) {
-                total++;
+            } else if (!WORK_ORDER_STATES.contains(state)) {
                 invalidWorkOrderStateCount++;
-                continue;
-            }
-            total++;
-            if (state == HANDLED_STATE) {
-                handled++;
-            } else if (state == 7) {
+            } else if (state == CANCELLED_STATE) {
                 cancelledCount++;
-            } else {
+            } else if (state != HANDLED_STATE) {
                 pending++;
             }
         }
 
-        // 处置率=范围内全部工单(状态 0–6)口径,两个 family 共用同一份范围读数
-        // (决策票 06:分母不要求关联报警/事件)。事件计数仍按 family 各自统计。
-        int rateNumerator = scopeRate.handled;
-        int rateDenominator = scopeRate.eligible;
-
         return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
-                eventFamily,
+                "ALARM",
                 total == 0 ? "EMPTY" : "READY",
                 total == 0 ? "NO_EVENTS" : "EVENTS_RESOLVED",
                 total,
@@ -218,9 +318,15 @@ public class CockpitOverviewWarningSettingsService {
                 cancelledCount,
                 unlinkedEventCount,
                 invalidWorkOrderStateCount,
-                scopeEfficiency(rateNumerator, rateDenominator),
+                // 报警族处置率仍是**工单口径**(决策票 06 / 维护者 2026-10-14 裁定)。
+                scopeEfficiency(scopeRate.handled, scopeRate.eligible),
                 scopeRate.stateCounts,
-                trend(snapshot, eventFamily, window));
+                statusBuckets(statusCounts, ALARM_STATUSES,
+                        CockpitOverviewWarningSettingsService::alarmStatusLabel),
+                levelBuckets(levelCounts),
+                industryBuckets(industryCounts),
+                districtBuckets(districtCounts),
+                trend(snapshot, "ALARM", window));
     }
 
     /**
@@ -251,8 +357,10 @@ public class CockpitOverviewWarningSettingsService {
     }
 
     /**
-     * 范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
-     * 按 {@code create_time} 界定窗口。两 family 共用同一份读数。
+     * 报警族范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
+     * 按 {@code create_time} 界定窗口。
+     *
+     * <p>只有报警族消费本读数 —— 预警族已按维护者裁定脱离工单。
      */
     private ScopeRate scopeRate(CockpitOverviewWarningSettingsSnapshot snapshot, Window window) {
         Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
@@ -298,6 +406,137 @@ public class CockpitOverviewWarningSettingsService {
         }
     }
 
+    /** 预警自身生命周期状态归一:只认 {@link WarningWorkflow} 六态,其余回 {@code null}(进未知桶)。 */
+    private static String normalizeWarningStatus(String status) {
+        if (status == null) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return WARNING_STATUSES.contains(value) ? value : null;
+    }
+
+    /** 报警自身生命周期状态归一:{@code alarm_status} 0/1,其余回 {@code null}(进未知桶)。 */
+    private static String normalizeAlarmStatus(String status) {
+        if (status == null) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return ALARM_STATUSES.contains(value) ? value : null;
+    }
+
+    /** 等级归一:{@code warning_level}/{@code alarm_level} 的 1–4 计算编码,其余回 {@code null}(进未知桶)。 */
+    private static Integer normalizeLevel(Integer level) {
+        return level != null && LEVELS.contains(level) ? level : null;
+    }
+
+    private static String warningStatusLabel(String code) {
+        return WarningWorkflow.label(code);
+    }
+
+    private static String alarmStatusLabel(String code) {
+        if (ALARM_HANDLED.equals(code)) {
+            return "已处理";
+        }
+        if (ALARM_UNHANDLED.equals(code)) {
+            return "未处理";
+        }
+        return UNKNOWN_LABEL;
+    }
+
+    private static String industryLabel(String code) {
+        if ("GAS".equals(code)) return "燃气";
+        if ("WATER_SUPPLY".equals(code)) return "供水";
+        if ("DRAINAGE".equals(code)) return "排水";
+        if ("MANHOLE".equals(code)) return "窨井";
+        return UNKNOWN_LABEL;
+    }
+
+    /**
+     * 状态分布:先按族的主线顺序铺全部已知状态(缺的态是真实计数 0,不是"没有数据"),
+     * 再在确有无法识别的状态时补一个未知桶 —— 故 `sum(桶计数) == total` 恒成立。
+     */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> statusBuckets(
+            Map<String, Integer> counts,
+            List<String> known,
+            Function<String, String> label) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (String code : known) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    code, label.apply(code), counts.getOrDefault(code, 0)));
+        }
+        int unknown = counts.getOrDefault(UNKNOWN_CODE, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /** 等级分布:1–4 全铺(缺的是真实 0),有脏等级时补未知桶。 */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> levelBuckets(
+            Map<Integer, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (Integer level : LEVELS) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    String.valueOf(level), level + "级", counts.getOrDefault(level, 0)));
+        }
+        int unknown = counts.getOrDefault(null, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /** 行业分布:四个正式行业全铺,推不出行业的进未知桶(不冒充)。 */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> industryBuckets(
+            Map<String, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (String industry : INDUSTRIES) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    industry, industryLabel(industry), counts.getOrDefault(industry, 0)));
+        }
+        int unknown = counts.getOrDefault(UNKNOWN_CODE, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /**
+     * 片区分布:0/1/2 三个正式片区全铺,另**恒有**「未分区」桶 ——
+     * 未分区是合法结果(设备缺失或片区未识别),不是缺数据。
+     */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> districtBuckets(
+            Map<Integer, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (Integer code : Arrays.asList(0, 1, 2)) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    String.valueOf(code), CockpitDistrict.nameOf(code), counts.getOrDefault(code, 0)));
+        }
+        buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                CockpitDistrict.UNASSIGNED_KEY,
+                CockpitDistrict.UNASSIGNED_NAME,
+                counts.getOrDefault(null, 0)));
+        return buckets;
+    }
+
+    /** 预警自身生命周期「已解除率」:分子=已解除,分母=预警总数;分母 0 → 不适用。 */
+    private static CockpitOverviewWarningSettingsResponse.Efficiency selfLifecycleEfficiency(
+            int closed, int total) {
+        BigDecimal value = total == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(closed)
+                        .divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP);
+        return new CockpitOverviewWarningSettingsResponse.Efficiency(
+                closed,
+                total,
+                value,
+                total == 0 ? "EMPTY" : "AVAILABLE",
+                total == 0 ? "NO_WARNINGS" : null);
+    }
+
     private static CockpitOverviewWarningSettingsResponse.Efficiency scopeEfficiency(int handled, int eligible) {
         BigDecimal value = eligible == 0
                 ? BigDecimal.ZERO.setScale(4)
@@ -311,7 +550,7 @@ public class CockpitOverviewWarningSettingsService {
                 eligible == 0 ? "NO_ELIGIBLE_WORK_ORDERS" : null);
     }
 
-    /** 范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
+    /** 报警族范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
     private static class ScopeRate {
         private final int handled;
         private final int eligible;

+ 47 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsSnapshot.java

@@ -31,8 +31,11 @@ public class CockpitOverviewWarningSettingsSnapshot {
     public List<SourceTrendPoint> getTrendPoints() { return trendPoints; }
 
     /**
-     * 范围内**全部工单**(决策票 06):处置率分母=状态 0–6 工单数,不要求事件回联;
-     * 按 {@code create_time} 界定窗口。事件趋势仍走 {@link SourceTrendPoint}。
+     * 范围内**全部工单**(决策票 06):**报警族**处置率分母=状态 0–6 工单数,
+     * 不要求事件回联;按 {@code create_time} 界定窗口。
+     *
+     * <p>维护者 2026-10-14 裁定:预警不走工单流程,故该读数**只服务报警族**,
+     * 预警族不再消费它。事件趋势仍走 {@link SourceTrendPoint}。
      */
     public List<SourceWorkOrder> getScopeWorkOrders() { return scopeWorkOrders; }
 
@@ -49,24 +52,57 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public String getCreateTime() { return createTime; }
     }
 
+    /**
+     * 一条事件事实(预警或报警),携带**该对象自身生命周期**与**沿设备解析的维度**。
+     *
+     * <p>{@code status} 是对象自身状态原样词:预警取 {@code WarningWorkflow} 六态
+     * ({@code DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED});报警取
+     * {@code alarm_status} 归一后的 {@code UNHANDLED}/{@code HANDLED}。两者都是「自身生命周期」,
+     * 不是工单状态 —— 工单状态另由 {@code workOrderState} 承载(只有报警族消费)。
+     *
+     * <p>{@code districtCode}/{@code industryCode} 由适配器沿设备解析后带入:服务层拿不到
+     * 设备台账,不能在服务层补算。推不出时如实为 {@code null}(未分区/未知行业),不冒充。
+     */
     public static class SourceEvent {
         private final String eventFamily;
         private final String eventCode;
         private final String deviceCode;
         private final String workOrderCode;
         private final Integer workOrderState;
+        private final String status;
+        private final Integer level;
+        private final Integer districtCode;
+        private final String industryCode;
 
+        /** 兼容旧调用点:只给工单回联事实,自身生命周期与维度留空。 */
         public SourceEvent(
                 String eventFamily,
                 String eventCode,
                 String deviceCode,
                 String workOrderCode,
                 Integer workOrderState) {
+            this(eventFamily, eventCode, deviceCode, workOrderCode, workOrderState, null, null, null, null);
+        }
+
+        public SourceEvent(
+                String eventFamily,
+                String eventCode,
+                String deviceCode,
+                String workOrderCode,
+                Integer workOrderState,
+                String status,
+                Integer level,
+                Integer districtCode,
+                String industryCode) {
             this.eventFamily = eventFamily;
             this.eventCode = eventCode;
             this.deviceCode = deviceCode;
             this.workOrderCode = workOrderCode;
             this.workOrderState = workOrderState;
+            this.status = status;
+            this.level = level;
+            this.districtCode = districtCode;
+            this.industryCode = industryCode;
         }
 
         public String getEventFamily() { return eventFamily; }
@@ -74,8 +110,17 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public String getDeviceCode() { return deviceCode; }
         public String getWorkOrderCode() { return workOrderCode; }
         public Integer getWorkOrderState() { return workOrderState; }
+        public String getStatus() { return status; }
+        public Integer getLevel() { return level; }
+        public Integer getDistrictCode() { return districtCode; }
+        public String getIndustryCode() { return industryCode; }
     }
 
+    /**
+     * 趋势桶。{@code handled} 的含义**按族不同**,不共用一套判据:
+     * 预警族=该桶内**已解除({@code CLOSED})**的预警数(自身生命周期);
+     * 报警族=该桶内**关联工单已结案**的报警数(工单口径,维护者裁定报警靠工单闭环)。
+     */
     public static class SourceTrendPoint {
         private final String eventFamily;
         private final String bucket;
@@ -95,4 +140,3 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public int getHandled() { return handled; }
     }
 }
-