Browse Source

feat(cockpit): split warning/alarm calibers on events/handling-efficiency

预警族改吃预警自身处置流程(票 74):预警不挂工单(实测 linkedWorkOrderCount=0),
原端点的工单口径对预警不成立,故:

- 新增 warningStateCounts:WarningWorkflow 六态全铺(缺态计 0),脏状态入 UNKNOWN 桶,
  sum(桶) == 窗口内预警条数;
- 新增 summary.warningHandlingEfficiency:已解除 ÷ 窗口内预警条数,
  分母 0 → state=EMPTY + NO_WARNINGS;
- 新增 summary.warningDuration:发布→已解除平均时长(来源 early_warning.update_time,
  由 Snapshot.SourceWarning 新增的 status/updateTime 下传),无样本 → EMPTY + NO_CLOSED_WARNINGS;
- 预警族的工单口径一律显式 null(不用 0 或别的族的数冒充):handledCount/inProgressCount/
  cancelledCount、handlingEfficiency、averageDuration、workOrderStateCounts、
  durationTrend[].handledCount/averageDuration(eventCount 保留,是预警真实发布量)。

报警族工单口径原样保留(工单是报警的处置手段),其 warning* 段为 null。
旧 6 参 SourceWarning 构造器保留为委托重载。

契约测试 Cockpit*Test,BusinessCockpit*Test 540/540(forkCount=0 本地沙箱执行)。
Kazerin 1 tuần trước cách đây
mục cha
commit
983fc27a68

+ 189 - 74
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyControllerTest.java

@@ -1,5 +1,6 @@
 package com.zksy.web.controller.cockpit;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencyAdapter;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencyService;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencySnapshot;
@@ -34,6 +35,17 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
+/**
+ * 预警效率端点契约(票 74 两族语义分流)。
+ *
+ * <p>两族各有一套口径,**不适用的一方显式为 null**:
+ * <ul>
+ *   <li>报警族:工单八态 + 工单口径处置率 + 关联工单结案时长(工单是报警的处置手段)。</li>
+ *   <li>预警族:预警自身六态 + 已解除率 + 发布→已解除平均时长;工单口径一律 null
+ *       (预警不挂工单,工单读数对预警不成立 —— 票 65 实测 linkedWorkOrderCount=0)。</li>
+ * </ul>
+ * 预警族/报警族的断言因此各自成组:工单口径用例走 ALARM,预警流程用例走 WARNING。
+ */
 class CockpitHandlingEfficiencyControllerTest {
 
     private static final String ENDPOINT = "/api/cockpit/v1/events/handling-efficiency";
@@ -56,7 +68,7 @@ class CockpitHandlingEfficiencyControllerTest {
     }
 
     @Test
-    void warningFamilyExposesSummaryExplicitBasisEightStatesTrendAndEvents() throws Exception {
+    void warningFamilyExposesOwnWorkflowAndNullsWorkOrderCaliber() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
@@ -72,53 +84,105 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.filters.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.filters.workOrderState").value(nullValue()))
+                // 事件口径两族共用(真实计数,不是第二轮读数)。
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(8))
                 .andExpect(jsonPath("$.data.summary.linkedWorkOrderCount").value(6))
                 .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(2))
-                .andExpect(jsonPath("$.data.summary.inProgressCount").value(3))
-                .andExpect(jsonPath("$.data.summary.cancelledCount").value(1))
-                // 处置率=状态 5 ÷ 状态 0–6 工单数(决策票 06)。范围工单为 0,3,4,5,5,7:
-                // 分子=2(状态 5)、分母=5(状态 0/3/4/5/5;状态 7 排除)→ 0.4000。
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.4000"))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.numerator").value(2))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.basis")
-                        .value("CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("1.67"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.unit").value("HOURS"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
-                .andExpect(jsonPath("$.data.summary.averageDuration.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(8)))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateCode").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateName").value("待派单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[1].stateName").value("待接单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[1].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[2].stateName").value("已接单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[2].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].stateName").value("处理中"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[4].stateName").value("待验收"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[5].stateName").value("已结案"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].stateName").value("延期中"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[7].stateName").value("已取消"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1))
+                // 票 74:**工单口径**对预警族不成立 → 显式 null(不是 0,也不是别的族的数)。
+                .andExpect(jsonPath("$.data.summary.handledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.inProgressCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.cancelledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.averageDuration").value(nullValue()))
+                .andExpect(jsonPath("$.data.workOrderStateCounts").value(nullValue()))
+                // 预警自身六态全铺(缺的态计 0),顺序即 WarningWorkflow 主线。
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(6)))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].stateCode").value("DRAFT"))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].stateName").value("草稿"))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].stateCode").value("PENDING"))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].stateName").value("待接收"))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[2].stateCode").value("RECEIVED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[2].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].stateCode").value("PROCESSING"))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[4].stateCode").value("HANDLED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[4].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].stateCode").value("CLOSED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].stateName").value("已解除"))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(3))
+                // 已解除率=已解除 ÷ 窗口内预警数(3/8),基线与工单口径不同名。
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.numerator").value(3))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(8))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.value").value("0.3750"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.basis")
+                        .value("CLOSED_WARNINGS_OVER_WINDOW_WARNINGS"))
+                // 发布→已解除平均时长:0.50h / 2.00h / 1.00h → 1.17h。
+                .andExpect(jsonPath("$.data.summary.warningDuration.value").value("1.17"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.unit").value("HOURS"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.sampleCount").value(3))
+                .andExpect(jsonPath("$.data.summary.warningDuration.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.events", hasSize(8)))
                 .andExpect(jsonPath("$.data.events[0].eventNo").value("EW-007"))
                 .andExpect(jsonPath("$.data.events[0].hasWorkOrder").value(true))
                 .andExpect(jsonPath("$.data.pagination.total").value(8))
+                // 趋势桶:事件发布量照实给,工单结案量与结案时长两族口径 → null。
                 .andExpect(jsonPath("$.data.durationTrend", hasSize(5)))
                 .andExpect(jsonPath("$.data.durationTrend[0].timeBucket").value("2026-09-02T08:00:00+08:00"))
-                .andExpect(jsonPath("$.data.durationTrend[4].timeBucket").value("2026-09-02T12:00:00+08:00"));
+                .andExpect(jsonPath("$.data.durationTrend[0].eventCount").value(2))
+                .andExpect(jsonPath("$.data.durationTrend[0].handledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.durationTrend[0].averageDuration").value(nullValue()))
+                .andExpect(jsonPath("$.data.durationTrend[4].timeBucket").value("2026-09-02T12:00:00+08:00"))
+                .andExpect(jsonPath("$.data.durationTrend[4].eventCount").value(0));
     }
 
     @Test
-    void alarmFamilyIsIndependentFromWarnings() throws Exception {
+    void warningFamilyPutsUnrecognisedStatusIntoUnknownBucketSoBucketsStillSumToTotal() throws Exception {
+        when(adapter.load()).thenReturn(fixtureWithDirtyWarningStatus());
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(3))
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(7)))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(1))
+                // 脏状态进 UNKNOWN(最后一个桶),不静默丢 —— 六个已知态 2 + UNKNOWN 1 = 3。
+                .andExpect(jsonPath("$.data.warningStateCounts[6].stateCode").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.warningStateCounts[6].stateName").value("未知"))
+                .andExpect(jsonPath("$.data.warningStateCounts[6].count").value(1))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(3));
+    }
+
+    @Test
+    void warningFamilyEmptyWindowReportsTypedEmptyStatesInsteadOfFakeZero() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", "2026-09-05T00:00:00+08:00")
+                        .param("endTime", "2026-09-05T02:00:00+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(0))
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(6)))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].count").value(0))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(0))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(0))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.reason").value("NO_WARNINGS"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.sampleCount").value(0))
+                .andExpect(jsonPath("$.data.summary.warningDuration.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.reason").value("NO_CLOSED_WARNINGS"))
+                // 预警族照样没有工单口径。
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.workOrderStateCounts").value(nullValue()));
+    }
+
+    @Test
+    void alarmFamilyKeepsWorkOrderCaliberAndNullsTheWarningSection() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "ALARM")
                         .param("startTime", START)
@@ -127,17 +191,28 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.eventFamily").value("ALARM"))
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
                 .andExpect(jsonPath("$.data.summary.handledCount").value(2))
+                .andExpect(jsonPath("$.data.summary.inProgressCount").value(3))
+                .andExpect(jsonPath("$.data.summary.cancelledCount").value(1))
                 // 处置率是**工单范围**读数,不随事件 family 变化(决策票 06:分母不要求事件回联)。
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.4000"))
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
+                // 报警族可算结案时长的样本只有 EA-001(EA-002 的工单还在处理中)→ 10:20→14:20 共 4 小时。
+                .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(1))
+                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("4.00"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1));
+                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
+                // 票 74:报警族不吃预警口径 —— 预警段显式 null,不用 0 冒充。
+                .andExpect(jsonPath("$.data.warningStateCounts").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.warningDuration").value(nullValue()))
+                // 报警族的趋势桶仍带工单口径(不是 null)。
+                .andExpect(jsonPath("$.data.durationTrend[0].handledCount").value(0));
     }
 
     @Test
     void everyWorkOrderStateIsReportedUnmerged() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -151,7 +226,7 @@ class CockpitHandlingEfficiencyControllerTest {
     @Test
     void emptyWindowReportsEmptyEfficiencyAndDurationStates() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", "2026-09-05T00:00:00+08:00")
                         .param("endTime", "2026-09-05T02:00:00+08:00"))
                 .andExpect(status().isOk())
@@ -164,11 +239,12 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(0))
                 .andExpect(jsonPath("$.data.summary.averageDuration.state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.reason")
-                        .value("NO_CLOSED_WORK_ORDERS"));
+                        .value("NO_CLOSED_WORK_ORDERS"))
+                .andExpect(jsonPath("$.data.warningStateCounts").value(nullValue()));
     }
 
     @Test
-    void eventsWithoutWorkOrdersAreCountedAsMissingAndKeepStateCountsStable() throws Exception {
+    void eventsWithoutWorkOrdersAreCountedAsMissing() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
@@ -184,7 +260,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithLegacyStateCodes());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -200,7 +276,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithMixedDurations());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -214,7 +290,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithDelayingAndCancelledOrders());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -231,8 +307,8 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithDelayingAndCancelledOrders());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
-                        // 事件在 08:10,工单在 09:00:窗口排除全部事件但含工单。
+                        .param("eventFamily", "ALARM")
+                        // 工单在 09:00,窗口 08:30 起:窗口排除事件但含工单。
                         .param("startTime", "2026-09-02T08:30:00+08:00")
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -247,7 +323,7 @@ class CockpitHandlingEfficiencyControllerTest {
     @Test
     void summaryNumeratorAndDenominatorMatchTheReportedStateDistribution() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -279,17 +355,19 @@ class CockpitHandlingEfficiencyControllerTest {
     }
 
     @Test
-    void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {        mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+    void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
                         .param("workOrderState", "5")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.filters.workOrderState").value(5))
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.pagination.total").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(1)))
                 .andExpect(jsonPath("$.data.mapFilter.workOrderState").value(5))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(1)))
+                // 状态分布是**范围工单**口径,不随 workOrderState 过滤收窄。
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1));
     }
@@ -409,19 +487,32 @@ class CockpitHandlingEfficiencyControllerTest {
                 && !method.isAnnotationPresent(DeleteMapping.class);
     }
 
+    /**
+     * 主样本:8 条在范围内预警(`EW-LEGACY` 的类型不在设备类型表里,被行业范围判定排除),
+     * 状态覆盖六态 —— DRAFT 1 / PENDING 1 / RECEIVED 1 / PROCESSING 1 / HANDLED 1 / CLOSED 3,
+     * 其中三条 CLOSED 的 `update_time` 分别距发布 0.50h / 2.00h / 1.00h。
+     */
     private static CockpitHandlingEfficiencySnapshot fixture() {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
                 Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 2, "2026-09-02T08:40:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
-                        warning("EW-003", 2, "2026-09-02T09:20:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-1", "供水压力计-001"),
-                        warning("EW-004", 3, "2026-09-02T10:05:00+08:00", "SEWAGE_ENV", "D-DR-1", "污水环境监测仪-001"),
-                        warning("EW-005", 4, "2026-09-02T10:45:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-2", "供水压力计-002"),
-                        warning("EW-006", 1, "2026-09-02T11:10:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
-                        warning("EW-007", 2, "2026-09-02T11:35:00+08:00", "SEWAGE_ENV", "D-DR-2", "污水环境监测仪-002"),
-                        warning("EW-008", 1, "2026-09-02T09:55:00+08:00", "SEWAGE_ENV", "D-DR-3", "污水环境监测仪-003"),
+                        warningWithWorkflow("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-1", "燃气浓度计-001", WarningWorkflow.CLOSED, "2026-09-02T08:40:00+08:00"),
+                        warningWithWorkflow("EW-002", 2, "2026-09-02T08:40:00+08:00", "GAS_SENSOR",
+                                "D-GAS-2", "燃气浓度计-002", WarningWorkflow.PROCESSING, null),
+                        warningWithWorkflow("EW-003", 2, "2026-09-02T09:20:00+08:00", "WATER_SUPPLY_SENSOR",
+                                "D-WS-1", "供水压力计-001", WarningWorkflow.RECEIVED, null),
+                        warningWithWorkflow("EW-004", 3, "2026-09-02T10:05:00+08:00", "SEWAGE_ENV",
+                                "D-DR-1", "污水环境监测仪-001", WarningWorkflow.CLOSED, "2026-09-02T12:05:00+08:00"),
+                        warningWithWorkflow("EW-005", 4, "2026-09-02T10:45:00+08:00", "WATER_SUPPLY_SENSOR",
+                                "D-WS-2", "供水压力计-002", WarningWorkflow.PENDING, null),
+                        warningWithWorkflow("EW-006", 1, "2026-09-02T11:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-3", "燃气浓度计-003", WarningWorkflow.HANDLED, null),
+                        warningWithWorkflow("EW-007", 2, "2026-09-02T11:35:00+08:00", "SEWAGE_ENV",
+                                "D-DR-2", "污水环境监测仪-002", WarningWorkflow.CLOSED, "2026-09-02T12:35:00+08:00"),
+                        warningWithWorkflow("EW-008", 1, "2026-09-02T09:55:00+08:00", "SEWAGE_ENV",
+                                "D-DR-3", "污水环境监测仪-003", WarningWorkflow.DRAFT, null),
                         warning("EW-LEGACY", 1, "2026-09-02T09:05:00+08:00", "LEGACY", "D-LEGACY", "旧设备")),
                 Arrays.asList(
                         alarm("EA-001", 1, "2026-09-02T10:20:00+08:00", "GAS_SENSOR", "D-GAS-9", "燃气浓度计-009"),
@@ -446,6 +537,23 @@ class CockpitHandlingEfficiencyControllerTest {
                         scopeOrder("WO-S7", 7, "GAS_SENSOR")));
     }
 
+    /** 一条脏状态(非六态字典值):必须进 UNKNOWN 桶,六态 + UNKNOWN 之和仍等于窗口内条数。 */
+    private static CockpitHandlingEfficiencySnapshot fixtureWithDirtyWarningStatus() {
+        return new CockpitHandlingEfficiencySnapshot(
+                "2026-09-02T11:59:50+08:00",
+                types(),
+                Arrays.asList(
+                        warningWithWorkflow("EW-U1", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-1", "燃气浓度计-001", WarningWorkflow.CLOSED, "2026-09-02T09:10:00+08:00"),
+                        warningWithWorkflow("EW-U2", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-2", "燃气浓度计-002", WarningWorkflow.PROCESSING, null),
+                        warningWithWorkflow("EW-U3", 1, "2026-09-02T10:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-3", "燃气浓度计-003", "WEIRD", null)),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+
     private static CockpitHandlingEfficiencySnapshot.SourceWorkOrder scopeOrder(
             String orderNo, Integer stateCode, String typeCode) {
         return new CockpitHandlingEfficiencySnapshot.SourceWorkOrder(
@@ -472,13 +580,13 @@ class CockpitHandlingEfficiencyControllerTest {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
-                Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002")),
                 Collections.emptyList(),
                 Arrays.asList(
-                        link("EW-001", 99, "2026-09-02T08:10:00+08:00", null),
-                        link("EW-002", 7, "2026-09-02T09:10:00+08:00", null)),
+                        alarm("EA-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
+                        alarm("EA-002", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002")),
+                Arrays.asList(
+                        link("EA-001", 99, "2026-09-02T08:10:00+08:00", null),
+                        link("EA-002", 7, "2026-09-02T09:10:00+08:00", null)),
                 // 旧码 99 与 7 都经字典收敛为 7(已取消)→ 分母 0、计数[7]=2。
                 Arrays.asList(
                         scopeOrder("WO-L1", 99, "GAS_SENSOR"),
@@ -489,17 +597,17 @@ class CockpitHandlingEfficiencyControllerTest {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
-                Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:00:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 1, "2026-09-02T09:00:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
-                        warning("EW-003", 1, "2026-09-02T10:00:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
-                        warning("EW-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
                 Collections.emptyList(),
                 Arrays.asList(
-                        link("EW-001", 5, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
-                        link("EW-002", 5, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
-                        link("EW-003", 5, "2026-09-02T10:00:00+08:00", null),
-                        link("EW-004", 3, "2026-09-02T11:00:00+08:00", null)),
+                        alarm("EA-001", 1, "2026-09-02T08:00:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
+                        alarm("EA-002", 1, "2026-09-02T09:00:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
+                        alarm("EA-003", 1, "2026-09-02T10:00:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
+                        alarm("EA-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
+                Arrays.asList(
+                        link("EA-001", 5, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
+                        link("EA-002", 5, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
+                        link("EA-003", 5, "2026-09-02T10:00:00+08:00", null),
+                        link("EA-004", 3, "2026-09-02T11:00:00+08:00", null)),
                 // 三张已结案工单 → handledCount 3、分母 3、处置率 1.0000。
                 Arrays.asList(
                         scopeOrder("WO-M1", 5, "GAS_SENSOR"),
@@ -523,6 +631,13 @@ class CockpitHandlingEfficiencyControllerTest {
                 no, level, eventTime, typeCode, deviceCode, deviceName);
     }
 
+    private static CockpitHandlingEfficiencySnapshot.SourceWarning warningWithWorkflow(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName,
+            String status, String updateTime) {
+        return new CockpitHandlingEfficiencySnapshot.SourceWarning(
+                no, level, eventTime, typeCode, deviceCode, deviceName, status, updateTime);
+    }
+
     private static CockpitHandlingEfficiencySnapshot.SourceAlarm alarm(
             String id, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
         return new CockpitHandlingEfficiencySnapshot.SourceAlarm(

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

@@ -474,7 +474,11 @@ public class BusinessCockpitEventAdapter {
                         offsetTime(item.warning.getPublishTime()),
                         typeCode(item.warning.getDeviceCode(), facts),
                         trimToNull(item.warning.getDeviceCode()),
-                        firstNonBlank(item.warning.getDeviceName(), deviceName(item.warning.getDeviceCode(), facts))))
+                        firstNonBlank(item.warning.getDeviceName(), deviceName(item.warning.getDeviceCode(), facts)),
+                        // 票 74:预警自身生命周期与解除时刻来源(update_time)随快照下传,
+                        // 供服务层按窗口分族聚合;工单口径与这两者无关。
+                        trimToNull(item.warning.getStatus()),
+                        offsetTime(item.warning.getUpdateTime())))
                 .collect(Collectors.toList());
     }
 

+ 56 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyResponse.java

@@ -19,7 +19,10 @@ public class CockpitHandlingEfficiencyResponse {
     private final Pagination pagination;
     private final MapFilter mapFilter;
     private final Summary summary;
+    /** 工单八态(报警族的工单口径);预警族为 {@code null}(不适用,不用 0 冒充)。 */
     private final List<WorkOrderStateCount> workOrderStateCounts;
+    /** 预警自身六态(预警族);报警族为 {@code null}。票 74 新增。 */
+    private final List<WarningStateCount> warningStateCounts;
     private final List<DurationPoint> durationTrend;
     private final List<EventItem> events;
 
@@ -37,6 +40,7 @@ public class CockpitHandlingEfficiencyResponse {
             MapFilter mapFilter,
             Summary summary,
             List<WorkOrderStateCount> workOrderStateCounts,
+            List<WarningStateCount> warningStateCounts,
             List<DurationPoint> durationTrend,
             List<EventItem> events) {
         this.requestId = requestId;
@@ -52,6 +56,7 @@ public class CockpitHandlingEfficiencyResponse {
         this.mapFilter = mapFilter;
         this.summary = summary;
         this.workOrderStateCounts = workOrderStateCounts;
+        this.warningStateCounts = warningStateCounts;
         this.durationTrend = durationTrend;
         this.events = events;
     }
@@ -69,6 +74,7 @@ public class CockpitHandlingEfficiencyResponse {
     public MapFilter getMapFilter() { return mapFilter; }
     public Summary getSummary() { return summary; }
     public List<WorkOrderStateCount> getWorkOrderStateCounts() { return workOrderStateCounts; }
+    public List<WarningStateCount> getWarningStateCounts() { return warningStateCounts; }
     public List<DurationPoint> getDurationTrend() { return durationTrend; }
     public List<EventItem> getEvents() { return events; }
     public CockpitFreshnessStatus getFreshness() {
@@ -149,25 +155,36 @@ public class CockpitHandlingEfficiencyResponse {
         public List<String> getDeviceCodes() { return deviceCodes; }
     }
 
+    /**
+     * 摘要。两族各有一套口径,**不适用的一方显式为 {@code null}**(票 74):
+     * 工单口径(handledCount/inProgressCount/cancelledCount/handlingEfficiency/averageDuration)
+     * 只属报警族 —— 工单是报警的处置手段;预警不挂工单,那三个计数若照旧回工单读数,
+     * 前端画出来的「预警处置中 = N」就是拿一个与预警无关的对象在统计预警。
+     * 预警族对应给 {@code warningHandlingEfficiency} / {@code warningDuration}。
+     */
     public static class Summary {
         private final int totalEvents;
         private final int linkedWorkOrderCount;
         private final int missingWorkOrderCount;
-        private final int handledCount;
-        private final int inProgressCount;
-        private final int cancelledCount;
+        private final Integer handledCount;
+        private final Integer inProgressCount;
+        private final Integer cancelledCount;
         private final HandlingEfficiency handlingEfficiency;
         private final AverageDuration averageDuration;
+        private final HandlingEfficiency warningHandlingEfficiency;
+        private final AverageDuration warningDuration;
 
         public Summary(
                 int totalEvents,
                 int linkedWorkOrderCount,
                 int missingWorkOrderCount,
-                int handledCount,
-                int inProgressCount,
-                int cancelledCount,
+                Integer handledCount,
+                Integer inProgressCount,
+                Integer cancelledCount,
                 HandlingEfficiency handlingEfficiency,
-                AverageDuration averageDuration) {
+                AverageDuration averageDuration,
+                HandlingEfficiency warningHandlingEfficiency,
+                AverageDuration warningDuration) {
             this.totalEvents = totalEvents;
             this.linkedWorkOrderCount = linkedWorkOrderCount;
             this.missingWorkOrderCount = missingWorkOrderCount;
@@ -176,16 +193,20 @@ public class CockpitHandlingEfficiencyResponse {
             this.cancelledCount = cancelledCount;
             this.handlingEfficiency = handlingEfficiency;
             this.averageDuration = averageDuration;
+            this.warningHandlingEfficiency = warningHandlingEfficiency;
+            this.warningDuration = warningDuration;
         }
 
         public int getTotalEvents() { return totalEvents; }
         public int getLinkedWorkOrderCount() { return linkedWorkOrderCount; }
         public int getMissingWorkOrderCount() { return missingWorkOrderCount; }
-        public int getHandledCount() { return handledCount; }
-        public int getInProgressCount() { return inProgressCount; }
-        public int getCancelledCount() { return cancelledCount; }
+        public Integer getHandledCount() { return handledCount; }
+        public Integer getInProgressCount() { return inProgressCount; }
+        public Integer getCancelledCount() { return cancelledCount; }
         public HandlingEfficiency getHandlingEfficiency() { return handlingEfficiency; }
         public AverageDuration getAverageDuration() { return averageDuration; }
+        public HandlingEfficiency getWarningHandlingEfficiency() { return warningHandlingEfficiency; }
+        public AverageDuration getWarningDuration() { return warningDuration; }
     }
 
     public static class HandlingEfficiency {
@@ -254,13 +275,35 @@ public class CockpitHandlingEfficiencyResponse {
         public int getCount() { return count; }
     }
 
+    /**
+     * 逐桶事件量与工单结案时长。{@code eventCount} 是**事件自身**的真实发布量(两族都可用);
+     * {@code handledCount}/{@code averageDuration} 是**工单口径**,预警族为 {@code null}(票 74)。
+     */
+    /** 预警自身生命周期桶:编码({@code WarningWorkflow} 六态或 UNKNOWN)+ 中文名 + 计数。 */
+    public static class WarningStateCount {
+        private final String stateCode;
+        private final String stateName;
+        private final int count;
+
+        public WarningStateCount(String stateCode, String stateName, int count) {
+            this.stateCode = stateCode;
+            this.stateName = stateName;
+            this.count = count;
+        }
+
+        public String getStateCode() { return stateCode; }
+        public String getStateName() { return stateName; }
+        public int getCount() { return count; }
+    }
+
     public static class DurationPoint {
         private final String timeBucket;
         private final int eventCount;
-        private final int handledCount;
+        private final Integer handledCount;
         private final AverageDuration averageDuration;
 
-        public DurationPoint(String timeBucket, int eventCount, int handledCount, AverageDuration averageDuration) {
+        public DurationPoint(
+                String timeBucket, int eventCount, Integer handledCount, AverageDuration averageDuration) {
             this.timeBucket = timeBucket;
             this.eventCount = eventCount;
             this.handledCount = handledCount;
@@ -269,7 +312,7 @@ public class CockpitHandlingEfficiencyResponse {
 
         public String getTimeBucket() { return timeBucket; }
         public int getEventCount() { return eventCount; }
-        public int getHandledCount() { return handledCount; }
+        public Integer getHandledCount() { return handledCount; }
         public AverageDuration getAverageDuration() { return averageDuration; }
     }
 

+ 228 - 49
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.event;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -30,10 +31,20 @@ import java.util.Set;
 /**
  * Read-only aggregation for 预警处置 / 预警效率.
  *
+ * <p><b>两族语义分流(票 74)</b>:
+ * <ul>
+ *   <li><b>报警族</b>保留工单口径 —— 工单是报警的处置手段:工单八态、工单口径处置率
+ *       (状态 5 ÷ 状态 0–6)、关联工单结案时长。</li>
+ *   <li><b>预警族只给预警自身流程</b> —— 预警不挂工单(实测 {@code linkedWorkOrderCount=0}),
+ *       故工单口径字段一律 {@code null}(不用 0、也不用别的族的数冒充),
+ *       改给 {@code warningStateCounts}({@link WarningWorkflow} 六态)、
+ *       {@code warningHandlingEfficiency}(已解除 ÷ 窗口内预警数)、
+ *       {@code warningDuration}(发布→已解除平均时长)。</li>
+ * </ul>
+ *
  * <p>Keeps every work-order state from {@link CockpitWorkOrderStateDictionary}
  * unmerged, states the efficiency basis explicitly and deliberately never judges
- * whether an event was a false positive or accurate. Handled means the linked
- * work order resolved to state 6 已结案.
+ * whether an event was a false positive or accurate.
  */
 @Service
 public class CockpitHandlingEfficiencyService {
@@ -46,6 +57,19 @@ public class CockpitHandlingEfficiencyService {
     private static final int CANCELLED_STATE_CODE = CockpitWorkOrderStateDictionary.CANCELLED_RATE_CODE;
     private static final String EFFICIENCY_BASIS = CockpitWorkOrderStateDictionary.HANDLING_RATE_BASIS;
     private static final String NO_ELIGIBLE_WORK_ORDERS = "NO_ELIGIBLE_WORK_ORDERS";
+    /** 预警族处置率口径:已解除预警 ÷ 窗口内预警数(票 74)。 */
+    private static final String WARNING_EFFICIENCY_BASIS = "CLOSED_WARNINGS_OVER_WINDOW_WARNINGS";
+    private static final String NO_WARNINGS = "NO_WARNINGS";
+    private static final String NO_CLOSED_WARNINGS = "NO_CLOSED_WARNINGS";
+    private static final String UNKNOWN_WARNING_STATUS = "UNKNOWN";
+    /** 预警自身生命周期主线({@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));
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
     private static final int DEFAULT_PAGE_SIZE = 100;
@@ -116,7 +140,8 @@ public class CockpitHandlingEfficiencyService {
             int pageSize) {
         Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types = types(state);
         Map<String, Link> links = links(state.getWorkOrders());
-        List<EventFact> facts = WARNING.equals(family)
+        boolean warningFamily = WARNING.equals(family);
+        List<EventFact> facts = warningFamily
                 ? warningFacts(state, types, industry, level)
                 : alarmFacts(state, types, industry, level);
         facts = inWindow(facts, window);
@@ -124,13 +149,18 @@ public class CockpitHandlingEfficiencyService {
         // 处置率口径(决策票 06 / 规格 10):分母=范围内**全部工单**(状态 0–6,
         // 状态 7 已取消排除),不要求关联报警/事件;范围按 create_time 界定。
         // 事件回联只决定 linkedCount / missingCount 与事件明细,不再参与分母。
-        Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
-        for (CockpitWorkOrderState workOrderStateEntry : CockpitWorkOrderStateDictionary.states()) {
-            stateCounts.put(workOrderStateEntry.getCode(), 0);
-        }
-        for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order
-                : scopeWorkOrders(state, types, industry, window)) {
-            stateCounts.merge(resolveStateCode(order.getStateCode()), 1, Integer::sum);
+        // **只属报警族**(票 74):预警不挂工单,整套工单读数为预警族不成立,
+        // 故预警族的 summary/stateCounts 一律 null,由 warning* 字段替代。
+        Map<Integer, Integer> stateCounts = null;
+        if (!warningFamily) {
+            stateCounts = new LinkedHashMap<>();
+            for (CockpitWorkOrderState workOrderStateEntry : CockpitWorkOrderStateDictionary.states()) {
+                stateCounts.put(workOrderStateEntry.getCode(), 0);
+            }
+            for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order
+                    : scopeWorkOrders(state, types, industry, window)) {
+                stateCounts.merge(resolveStateCode(order.getStateCode()), 1, Integer::sum);
+            }
         }
         int linkedCount = 0;
         int missingCount = 0;
@@ -179,41 +209,78 @@ public class CockpitHandlingEfficiencyService {
                     link != null));
         }
 
-        // Summary metrics: 事件计数取自事件范围,处置率取自工单范围(两者口径不同)。
-        int handledCount = stateCounts.getOrDefault(CLOSED_STATE_CODE, 0);
-        int cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
-        int inProgressCount = 0;
-        for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
-            if (entry.getKey() == 6 || (entry.getKey() >= 0 && entry.getKey() <= 4)) {
-                inProgressCount += entry.getValue();
-            }
-        }
         int totalEvents = facts.size();
-        int eligibleWorkOrders = 0;
-        for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
-            if (CockpitWorkOrderStateDictionary.isRateEligible(entry.getKey())) {
-                eligibleWorkOrders += entry.getValue();
-            }
-        }
 
-        List<BigDecimal> closedDurations = new ArrayList<>();
-        for (EventFact fact : facts) {
-            Link link = linkOf(links, fact);
-            if (link == null || link.stateCode != CLOSED_STATE_CODE) {
-                continue;
+        // 两族各算各的口径,不适用的一方显式 null(票 74)。
+        Integer handledCount = null;
+        Integer inProgressCount = null;
+        Integer cancelledCount = null;
+        CockpitHandlingEfficiencyResponse.HandlingEfficiency handlingEfficiency = null;
+        CockpitHandlingEfficiencyResponse.AverageDuration averageDuration = null;
+        List<CockpitHandlingEfficiencyResponse.WorkOrderStateCount> stateRows = null;
+        List<CockpitHandlingEfficiencyResponse.WarningStateCount> warningStateRows = null;
+        CockpitHandlingEfficiencyResponse.HandlingEfficiency warningEfficiency = null;
+        CockpitHandlingEfficiencyResponse.AverageDuration warningDuration = null;
+
+        if (warningFamily) {
+            Map<String, Integer> statusCounts = new LinkedHashMap<>();
+            int closedWarnings = 0;
+            List<BigDecimal> warningDurations = new ArrayList<>();
+            for (EventFact fact : facts) {
+                String status = normalizeWarningStatus(fact.status);
+                // 无法识别的状态如实进 UNKNOWN 桶,不静默丢(丢了 sum(桶) 就小于总条数)。
+                statusCounts.merge(status == null ? UNKNOWN_WARNING_STATUS : status, 1, Integer::sum);
+                if (!WarningWorkflow.CLOSED.equals(status)) {
+                    continue;
+                }
+                closedWarnings++;
+                BigDecimal duration = warningDurationHours(fact);
+                if (duration != null) {
+                    warningDurations.add(duration);
+                }
             }
-            BigDecimal duration = durationHours(link);
-            if (duration != null) {
-                closedDurations.add(duration);
+            warningStateRows = warningStateRows(statusCounts);
+            warningEfficiency = warningEfficiency(closedWarnings, totalEvents);
+            warningDuration = warningAverageDuration(warningDurations);
+        } else {
+            // Summary metrics: 事件计数取自事件范围,处置率取自工单范围(两者口径不同)。
+            handledCount = stateCounts.getOrDefault(CLOSED_STATE_CODE, 0);
+            cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
+            int inProgress = 0;
+            for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
+                if (entry.getKey() == 6 || (entry.getKey() >= 0 && entry.getKey() <= 4)) {
+                    inProgress += entry.getValue();
+                }
+            }
+            inProgressCount = inProgress;
+            int eligibleWorkOrders = 0;
+            for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
+                if (CockpitWorkOrderStateDictionary.isRateEligible(entry.getKey())) {
+                    eligibleWorkOrders += entry.getValue();
+                }
             }
-        }
 
-        List<CockpitHandlingEfficiencyResponse.WorkOrderStateCount> stateRows = new ArrayList<>();
-        for (CockpitWorkOrderState entry : CockpitWorkOrderStateDictionary.states()) {
-            stateRows.add(new CockpitHandlingEfficiencyResponse.WorkOrderStateCount(
-                    entry.getCode(),
-                    entry.getName(),
-                    stateCounts.getOrDefault(entry.getCode(), 0)));
+            List<BigDecimal> closedDurations = new ArrayList<>();
+            for (EventFact fact : facts) {
+                Link link = linkOf(links, fact);
+                if (link == null || link.stateCode != CLOSED_STATE_CODE) {
+                    continue;
+                }
+                BigDecimal duration = durationHours(link);
+                if (duration != null) {
+                    closedDurations.add(duration);
+                }
+            }
+
+            handlingEfficiency = efficiency(handledCount, eligibleWorkOrders);
+            averageDuration = averageDuration(closedDurations);
+            stateRows = new ArrayList<>();
+            for (CockpitWorkOrderState entry : CockpitWorkOrderStateDictionary.states()) {
+                stateRows.add(new CockpitHandlingEfficiencyResponse.WorkOrderStateCount(
+                        entry.getCode(),
+                        entry.getName(),
+                        stateCounts.getOrDefault(entry.getCode(), 0)));
+            }
         }
 
         return new CockpitHandlingEfficiencyResponse(
@@ -240,9 +307,12 @@ public class CockpitHandlingEfficiencyService {
                         handledCount,
                         inProgressCount,
                         cancelledCount,
-                        efficiency(handledCount, eligibleWorkOrders),
-                        averageDuration(closedDurations)),
+                        handlingEfficiency,
+                        averageDuration,
+                        warningEfficiency,
+                        warningDuration),
                 stateRows,
+                warningStateRows,
                 durationTrend(state, family, types, industry, level, links, window),
                 items);
     }
@@ -286,6 +356,93 @@ public class CockpitHandlingEfficiencyService {
                 null);
     }
 
+    /**
+     * 预警族处置率=**已解除预警数 ÷ 窗口内预警条数**(同上层的报警族工单口径刻意不同源)。
+     * 分母为 0 → {@code state=EMPTY} 且 {@code reason=NO_WARNINGS},前端显示「不适用」而非 0%。
+     */
+    private static CockpitHandlingEfficiencyResponse.HandlingEfficiency warningEfficiency(
+            int closedWarnings, int totalWarnings) {
+        if (totalWarnings <= 0) {
+            return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                    BigDecimal.ZERO.setScale(4), closedWarnings, totalWarnings,
+                    "EMPTY", NO_WARNINGS, WARNING_EFFICIENCY_BASIS);
+        }
+        return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                BigDecimal.valueOf(closedWarnings)
+                        .divide(BigDecimal.valueOf(totalWarnings), 4, RoundingMode.HALF_UP),
+                closedWarnings,
+                totalWarnings,
+                "AVAILABLE",
+                null,
+                WARNING_EFFICIENCY_BASIS);
+    }
+
+    /**
+     * 预警发布→已解除平均时长。无样本 → {@code state=EMPTY} + {@code NO_CLOSED_WARNINGS},
+     * 不回 0 时冒充真实读数。
+     *
+     * <p>解除时刻来源是 {@code early_warning.update_time}:该表没有独立的解除时间列,
+     * 而解除动作({@code clearWarning})在置 {@code CLOSED} 的同一次写入里显式更新它,
+     * 且 {@code CLOSED} 是终态无后续业务动作 —— 故 CLOSED 行上它即解除时刻。
+     * 这是**有前提的读数**,不假装是独立采集的时间戳;时间缺失/倒挂的样本跳过,不补 0。
+     */
+    private static CockpitHandlingEfficiencyResponse.AverageDuration warningAverageDuration(
+            List<BigDecimal> durations) {
+        if (durations.isEmpty()) {
+            return new CockpitHandlingEfficiencyResponse.AverageDuration(
+                    BigDecimal.ZERO.setScale(2), "HOURS", 0, "EMPTY", NO_CLOSED_WARNINGS);
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        for (BigDecimal duration : durations) {
+            sum = sum.add(duration);
+        }
+        return new CockpitHandlingEfficiencyResponse.AverageDuration(
+                sum.divide(BigDecimal.valueOf(durations.size()), 2, RoundingMode.HALF_UP),
+                "HOURS",
+                durations.size(),
+                "AVAILABLE",
+                null);
+    }
+
+    private static BigDecimal warningDurationHours(EventFact fact) {
+        OffsetDateTime published = parseEventTime(fact.eventTime);
+        OffsetDateTime resolved = parseEventTime(fact.updateTime);
+        if (published == null || resolved == null || resolved.isBefore(published)) {
+            return null;
+        }
+        long minutes = Duration.between(published, resolved).toMinutes();
+        return BigDecimal.valueOf(minutes)
+                .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 六态**全铺**(缺的态是真实计数 0,不是「没有数据」),确有无法识别的状态时补 {@code UNKNOWN} 桶,
+     * 故 {@code sum(桶计数) == 窗口内预警条数} 恒成立。
+     */
+    private static List<CockpitHandlingEfficiencyResponse.WarningStateCount> warningStateRows(
+            Map<String, Integer> statusCounts) {
+        List<CockpitHandlingEfficiencyResponse.WarningStateCount> rows = new ArrayList<>();
+        for (String status : WARNING_STATUSES) {
+            rows.add(new CockpitHandlingEfficiencyResponse.WarningStateCount(
+                    status, WarningWorkflow.label(status), statusCounts.getOrDefault(status, 0)));
+        }
+        int unknown = statusCounts.getOrDefault(UNKNOWN_WARNING_STATUS, 0);
+        if (unknown > 0) {
+            rows.add(new CockpitHandlingEfficiencyResponse.WarningStateCount(
+                    UNKNOWN_WARNING_STATUS, "未知", unknown));
+        }
+        return rows;
+    }
+
+    /** 只认 {@link WarningWorkflow} 六态;脏值/空值回 {@code null}(由调用方归入未知桶)。 */
+    private static String normalizeWarningStatus(String status) {
+        if (isBlank(status)) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return WARNING_STATUSES.contains(value) ? value : null;
+    }
+
     private List<CockpitHandlingEfficiencyResponse.DurationPoint> durationTrend(
             CockpitHandlingEfficiencySnapshot snapshot,
             String family,
@@ -307,7 +464,8 @@ public class CockpitHandlingEfficiencyService {
             eventCounts.put(key, 0);
             handledCounts.put(key, 0);
         }
-        List<EventFact> facts = WARNING.equals(family)
+        boolean warningFamily = WARNING.equals(family);
+        List<EventFact> facts = warningFamily
                 ? warningFacts(snapshot, types, industry, level)
                 : alarmFacts(snapshot, types, industry, level);
         for (EventFact fact : facts) {
@@ -321,6 +479,10 @@ public class CockpitHandlingEfficiencyService {
                 continue;
             }
             eventCounts.put(key, current + 1);
+            // 预警族只取**事件自身**的发布量;工单结案数与结案时长是报警族的口径(票 74)。
+            if (warningFamily) {
+                continue;
+            }
             Link link = linkOf(links, fact);
             if (link != null && link.stateCode == CLOSED_STATE_CODE) {
                 handledCounts.put(key, handledCounts.get(key) + 1);
@@ -335,8 +497,8 @@ public class CockpitHandlingEfficiencyService {
             points.add(new CockpitHandlingEfficiencyResponse.DurationPoint(
                     key,
                     eventCounts.get(key),
-                    handledCounts.get(key),
-                    averageDuration(durations.get(key))));
+                    warningFamily ? null : handledCounts.get(key),
+                    warningFamily ? null : averageDuration(durations.get(key))));
         }
         return points;
     }
@@ -361,7 +523,9 @@ public class CockpitHandlingEfficiencyService {
                     warning.getLevel(),
                     warning.getEventTime(),
                     warning.getDeviceCode(),
-                    warning.getDeviceName()));
+                    warning.getDeviceName(),
+                    warning.getStatus(),
+                    warning.getUpdateTime()));
         }
         return facts;
     }
@@ -386,7 +550,9 @@ public class CockpitHandlingEfficiencyService {
                     alarm.getLevel(),
                     alarm.getEventTime(),
                     alarm.getDeviceCode(),
-                    alarm.getDeviceName()));
+                    alarm.getDeviceName(),
+                    null,
+                    null));
         }
         return facts;
     }
@@ -665,13 +831,26 @@ public class CockpitHandlingEfficiencyService {
         private final String eventTime;
         private final String deviceCode;
         private final String deviceName;
-
-        private EventFact(String eventNo, Integer level, String eventTime, String deviceCode, String deviceName) {
+        /** 预警自身生命周期状态;报警族为 null。 */
+        private final String status;
+        /** 预警记录最后更新时间(解除时刻来源);报警族为 null。 */
+        private final String updateTime;
+
+        private EventFact(
+                String eventNo,
+                Integer level,
+                String eventTime,
+                String deviceCode,
+                String deviceName,
+                String status,
+                String updateTime) {
             this.eventNo = eventNo;
             this.level = level;
             this.eventTime = eventTime;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.status = status;
+            this.updateTime = updateTime;
         }
 
         private String getEventNo() {

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

@@ -109,6 +109,10 @@ public class CockpitHandlingEfficiencySnapshot {
         private final String typeCode;
         private final String deviceCode;
         private final String deviceName;
+        /** 预警自身生命周期({@code WarningWorkflow} 六态);脏值由服务层归入未知桶。 */
+        private final String status;
+        /** 预警记录最后更新时间:{@code CLOSED} 是终态且解除动作会写入该时间,故用作解除时刻来源。 */
+        private final String updateTime;
 
         public SourceWarning(
                 String warningNo,
@@ -117,12 +121,26 @@ public class CockpitHandlingEfficiencySnapshot {
                 String typeCode,
                 String deviceCode,
                 String deviceName) {
+            this(warningNo, level, eventTime, typeCode, deviceCode, deviceName, null, null);
+        }
+
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String deviceName,
+                String status,
+                String updateTime) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.status = status;
+            this.updateTime = updateTime;
         }
 
         public String getWarningNo() { return warningNo; }
@@ -131,6 +149,8 @@ public class CockpitHandlingEfficiencySnapshot {
         public String getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
+        public String getStatus() { return status; }
+        public String getUpdateTime() { return updateTime; }
     }
 
     public static class SourceAlarm {