Prechádzať zdrojové kódy

feat(cockpit): add warning handling efficiency (#41)

Kazerin 1 mesiac pred
rodič
commit
2c8fb911ce

+ 254 - 0
docs/cockpit/handling-efficiency-demo.html

@@ -0,0 +1,254 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>预警处置 / 预警效率</title>
+  <style>
+    :root { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; }
+    * { box-sizing:border-box; }
+    body { margin:0; min-height:100vh; background:var(--page); color:var(--ink); font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
+    header { display:flex; flex-wrap:wrap; gap:12px; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:18px; }
+    .tabs { display:inline-flex; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
+    .tabs button { border:0; border-right:1px solid var(--line); padding:8px 16px; background:#f4f7fa; }
+    .tabs button.on { background:var(--blue); color:#fff; }
+    .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
+    .meta b { color:var(--ink); font-weight:600; }
+    button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
+    select { border:1px solid #b9c8d5; border-radius:4px; padding:6px 8px; background:#fff; }
+    main { display:grid; grid-template-columns:minmax(360px,450px) minmax(0,1fr); min-height:calc(100vh - 57px); }
+    aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
+    section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; }
+    h2 { margin:0 0 10px; font-size:14px; }
+    .kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
+    .kpi { border:1px solid var(--line); border-radius:5px; padding:8px 10px; }
+    .kpi span { display:block; color:var(--muted); font-size:11px; }
+    .kpi b { display:block; margin-top:2px; font-size:17px; }
+    .basis { border-left:3px solid var(--blue); background:#f5f8fc; padding:8px 10px; color:var(--muted); margin-bottom:14px; font-size:12px; }
+    table { width:100%; border-collapse:collapse; font-size:12px; }
+    th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
+    th { color:var(--muted); font-weight:600; }
+    .statebars { display:grid; gap:5px; margin-bottom:14px; }
+    .staterow { display:grid; grid-template-columns:78px 1fr 34px; gap:8px; align-items:center; cursor:pointer; }
+    .staterow .bar { height:12px; background:#e6ecf2; border-radius:3px; overflow:hidden; }
+    .staterow .bar i { display:block; height:100%; background:var(--blue); }
+    .staterow.on .bar i { background:#0f8a4d; }
+    .staterow.on { font-weight:600; }
+    .toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; padding:10px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
+    .chart { display:flex; align-items:flex-end; gap:8px; height:150px; padding:10px 18px; background:#fff; border-bottom:1px solid var(--line); }
+    .chart .col { flex:1; min-width:22px; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:4px; }
+    .chart .col i { display:block; width:100%; background:#b65c08; border-radius:3px 3px 0 0; }
+    .chart .col small { font-size:10px; color:var(--muted); }
+    .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; min-height:160px; }
+    .marker { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
+    .label { position:absolute; font-size:11px; transform:translate(9px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
+    .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
+    .empty { color:var(--muted); padding:12px; border:1px dashed var(--line); border-radius:5px; margin-top:10px; }
+    @media (max-width:940px){ main{grid-template-columns:1fr;} }
+  </style>
+</head>
+<body>
+<header>
+  <h1>预警处置 / 预警效率</h1>
+  <div class="tabs">
+    <button id="tabWARNING" class="on" type="button">预警</button>
+    <button id="tabALARM" type="button">报警</button>
+  </div>
+  <div class="meta"><span>事件族 <b id="familyName">预警</b></span><span>范围 <b id="rangeText">--</b></span><span>更新 <b id="updated">--</b></span></div>
+</header>
+<main>
+  <aside>
+    <h2>效率 KPI</h2>
+    <div class="kpis">
+      <div class="kpi"><span>事件总数</span><b id="totalEvents">0</b></div>
+      <div class="kpi"><span>已结案</span><b id="handledCount">0</b></div>
+      <div class="kpi"><span>处理中(1-5)</span><b id="inProgress">0</b></div>
+      <div class="kpi"><span>已取消(7)</span><b id="cancelled">0</b></div>
+      <div class="kpi"><span>无关联工单</span><b id="missing">0</b></div>
+      <div class="kpi"><span>处置效率</span><b id="efficiency">0%</b></div>
+    </div>
+    <div class="basis">处置效率口径:已结案工单数 ÷ 事件总数(basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS)。本页不做预警真伪或缺报/误报判断。</div>
+
+    <h2>工单状态分布(七态不合并,点击筛选)</h2>
+    <div class="statebars" id="stateBars"></div>
+
+    <h2>平均处置耗时</h2>
+    <div class="kpis">
+      <div class="kpi"><span>平均耗时(小时)</span><b id="avgDuration">--</b></div>
+      <div class="kpi"><span>样本数</span><b id="avgSamples">0</b></div>
+      <div class="kpi"><span>统计范围</span><b>已结案</b></div>
+    </div>
+
+    <h2 style="margin-top:14px">事件列表</h2>
+    <table><thead><tr><th>事件</th><th>设备</th><th>等级</th><th>工单状态</th><th>耗时(h)</th></tr></thead>
+      <tbody id="eventRows"></tbody></table>
+    <div class="empty" id="empty" style="display:none">当前筛选下没有事件。</div>
+  </aside>
+  <section>
+    <div class="toolbar">
+      <strong>每小时平均处置耗时趋势</strong>
+      <span style="color:var(--muted)">柱高 = 该小时已结案工单的平均耗时</span>
+    </div>
+    <div class="chart" id="chart"></div>
+    <div class="toolbar">
+      <strong>中央一张图</strong>
+      <span id="filterLabel" style="color:var(--muted)">未筛选</span>
+      <button id="clearFilter" type="button">清除状态筛选</button>
+      <button id="refresh" type="button">手动刷新</button>
+    </div>
+    <div class="map" id="map"></div>
+    <div class="caption" id="caption">点击左侧状态条筛选事件与地图点位</div>
+  </section>
+</main>
+<script>
+  const STATES = [
+    { code: 1, name: "待派单" }, { code: 2, name: "待接单" }, { code: 3, name: "已接单" },
+    { code: 4, name: "处理中" }, { code: 5, name: "待验收" }, { code: 6, name: "已结案" },
+    { code: 7, name: "已取消" }
+  ];
+
+  const makeFixture = (prefix, specs) => specs.map((s, i) => ({
+    no: prefix + String(i + 1).padStart(3, "0"),
+    device: `D-${s.ind}-${i + 1}`,
+    level: s.level, hour: s.hour, state: s.state, duration: s.duration,
+    lon: 110.13 + i * 0.005, lat: 28.44 + (i % 5) * 0.008
+  }));
+
+  const fixture = {
+    WARNING: makeFixture("EW-", [
+      { ind: "GAS", level: 1, hour: 8, state: 1, duration: null },
+      { ind: "GAS", level: 2, hour: 8, state: 6, duration: 2.0 },
+      { ind: "WS", level: 2, hour: 9, state: 4, duration: null },
+      { ind: "DR", level: 3, hour: 9, state: 5, duration: null },
+      { ind: "WS", level: 4, hour: 10, state: 6, duration: 1.5 },
+      { ind: "GAS", level: 1, hour: 10, state: 7, duration: null },
+      { ind: "DR", level: 2, hour: 11, state: null, duration: null },
+      { ind: "DR", level: 1, hour: 11, state: null, duration: null },
+      { ind: "GAS", level: 1, hour: 11, state: 6, duration: 3.25 }
+    ]),
+    ALARM: makeFixture("EA-", [
+      { ind: "GAS", level: 1, hour: 8, state: 6, duration: 4.0 },
+      { ind: "WS", level: 2, hour: 10, state: 4, duration: null },
+      { ind: "DR", level: 1, hour: 11, state: null, duration: null }
+    ])
+  };
+
+  let family = "WARNING";
+  let stateFilter = null;
+
+  function render() {
+    const all = fixture[family];
+    const scoped = all;
+    const stateCounts = {};
+    STATES.forEach(s => stateCounts[s.code] = 0);
+    let linked = 0, missing = 0;
+    scoped.forEach(e => {
+      if (e.state === null) missing++;
+      else { linked++; stateCounts[e.state]++; }
+    });
+    const handled = stateCounts[6];
+    const inProgress = [1, 2, 3, 4, 5].reduce((n, c) => n + stateCounts[c], 0);
+    const durations = scoped.filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
+    const avg = durations.length ? (durations.reduce((a, b) => a + b, 0) / durations.length) : null;
+
+    const filtered = stateFilter === null
+      ? scoped
+      : stateFilter === "NONE" ? scoped.filter(e => e.state === null)
+      : scoped.filter(e => e.state === stateFilter);
+
+    document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
+    document.getElementById("rangeText").textContent = "2026-09-02 08:00 ~ 12:00 (+08:00)";
+    document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
+    document.getElementById("totalEvents").textContent = scoped.length;
+    document.getElementById("handledCount").textContent = handled;
+    document.getElementById("inProgress").textContent = inProgress;
+    document.getElementById("cancelled").textContent = stateCounts[7];
+    document.getElementById("missing").textContent = missing;
+    document.getElementById("efficiency").textContent =
+      scoped.length ? (handled / scoped.length * 100).toFixed(1) + "%" : "0.0%";
+    document.getElementById("avgDuration").textContent = avg === null ? "--" : avg.toFixed(2);
+    document.getElementById("avgSamples").textContent = durations.length;
+
+    const maxState = Math.max(1, ...Object.values(stateCounts));
+    document.getElementById("stateBars").innerHTML = STATES.map(s => `
+      <div class="staterow ${stateFilter === s.code ? "on" : ""}" data-code="${s.code}">
+        <span>${s.code} ${s.name}</span>
+        <span class="bar"><i style="width:${stateCounts[s.code] / maxState * 100}%"></i></span>
+        <span>${stateCounts[s.code]}</span>
+      </div>`).join("");
+    document.querySelectorAll(".staterow").forEach(row =>
+      row.addEventListener("click", () => {
+        const code = Number(row.dataset.code);
+        stateFilter = stateFilter === code ? null : code;
+        render();
+      }));
+
+    document.getElementById("empty").style.display = filtered.length ? "none" : "block";
+    document.getElementById("eventRows").innerHTML = filtered.map(e => `
+      <tr>
+        <td>${e.no}</td><td>${e.device}</td>
+        <td>${e.level}</td>
+        <td>${e.state === null ? '<span style="color:#64748b">无关联工单</span>' : STATES.find(s => s.code === e.state).name}</td>
+        <td>${e.duration === null ? "--" : e.duration.toFixed(2)}</td>
+      </tr>`).join("");
+
+    const hours = {};
+    scoped.forEach(e => {
+      const bucket = "0" + e.hour + ":00";
+      (hours[bucket] = hours[bucket] || []).push(e);
+    });
+    const bucketKeys = Object.keys(hours).sort();
+    const maxAvg = Math.max(0.5, ...bucketKeys.map(k => {
+      const ds = hours[k].filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
+      return ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : 0;
+    }));
+    document.getElementById("chart").innerHTML = bucketKeys.map(k => {
+      const ds = hours[k].filter(e => e.state === 6 && e.duration !== null).map(e => e.duration);
+      const value = ds.length ? ds.reduce((a, b) => a + b, 0) / ds.length : 0;
+      return `<div class="col"><small>${value ? value.toFixed(1) + "h" : "--"}</small>
+        <i style="height:${value / maxAvg * 110}px"></i><small>${k}</small></div>`;
+    }).join("");
+
+    const map = document.getElementById("map");
+    map.innerHTML = "";
+    document.getElementById("filterLabel").textContent =
+      stateFilter === null ? "未筛选"
+        : stateFilter === "NONE" ? "无关联工单"
+        : STATES.find(s => s.code === stateFilter).name;
+    if (filtered.length) {
+      const lons = filtered.map(e => e.lon), lats = filtered.map(e => e.lat);
+      const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
+      const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
+      const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
+      filtered.forEach(e => {
+        const el = document.createElement("div");
+        el.className = "marker";
+        el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
+        el.title = e.no;
+        map.appendChild(el);
+        const l = document.createElement("div");
+        l.className = "label"; l.style.left = xs(e) + "%"; l.style.top = ys(e) + "%";
+        l.textContent = e.no;
+        map.appendChild(l);
+      });
+    }
+    document.getElementById("caption").textContent =
+      `${filtered.length} 个事件点位(状态筛选:${document.getElementById("filterLabel").textContent})`;
+  }
+
+  document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
+  document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
+  function setFamily(next) {
+    family = next;
+    stateFilter = null;
+    document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
+    document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
+    render();
+  }
+  document.getElementById("clearFilter").addEventListener("click", () => { stateFilter = null; render(); });
+  document.getElementById("refresh").addEventListener("click", render);
+  render();
+</script>
+</body>
+</html>

+ 80 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyController.java

@@ -0,0 +1,80 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitHandlingEfficiencyService;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitHandlingEfficiencyController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitHandlingEfficiencyService handlingEfficiencyService;
+
+    public CockpitHandlingEfficiencyController(
+            CockpitHandlingEfficiencyService handlingEfficiencyService) {
+        this.handlingEfficiencyService = handlingEfficiencyService;
+    }
+
+    @GetMapping("/api/cockpit/v1/events/handling-efficiency")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> handlingEfficiency(
+            @RequestParam(value = "eventFamily", required = false) String eventFamily,
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "level", required = false) String level,
+            @RequestParam(value = "workOrderState", required = false) String workOrderState,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "page", defaultValue = "1") Integer page,
+            @RequestParam(value = "pageSize", defaultValue = "100") Integer pageSize,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                handlingEfficiencyService.load(
+                        resolveRequestId(requestId),
+                        eventFamily,
+                        industry,
+                        level,
+                        workOrderState,
+                        startTime,
+                        endTime,
+                        page,
+                        pageSize)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, handlingEfficiencyService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, handlingEfficiencyService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
+        return ResponseEntity.status(status)
+                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

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

@@ -0,0 +1,425 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitHandlingEfficiencyAdapter;
+import com.zksy.cockpit.event.CockpitHandlingEfficiencyService;
+import com.zksy.cockpit.event.CockpitHandlingEfficiencySnapshot;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+
+import java.lang.reflect.Method;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitHandlingEfficiencyControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/events/handling-efficiency";
+    private static final String START = "2026-09-02T08:00:00+08:00";
+    private static final String END = "2026-09-02T12:00:00+08:00";
+
+    private CockpitHandlingEfficiencyAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitHandlingEfficiencyAdapter.class);
+        CockpitHandlingEfficiencyService service = new CockpitHandlingEfficiencyService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitHandlingEfficiencyController(service))
+                .build();
+        when(adapter.load()).thenReturn(fixture());
+    }
+
+    @Test
+    void warningFamilyExposesSummaryExplicitBasisSevenStatesTrendAndEvents() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .header("X-Request-Id", "efficiency-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("efficiency-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.displayName").value("预警效率"))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .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))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.2500"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.numerator").value(2))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(8))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.basis")
+                        .value("CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS"))
+                .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(7)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateCode").value(1))
+                .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(1))
+                .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))
+                .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"));
+    }
+
+    @Test
+    void alarmFamilyIsIndependentFromWarnings() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.eventFamily").value("ALARM"))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.5000"))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1));
+    }
+
+    @Test
+    void everyWorkOrderStateIsReportedUnmerged() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(7)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1));
+    }
+
+    @Test
+    void emptyWindowReportsEmptyEfficiencyAndDurationStates() 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.summary.handlingEfficiency.value").value("0.0000"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.reason").value("NO_EVENTS"))
+                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("0.00"))
+                .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"));
+    }
+
+    @Test
+    void eventsWithoutWorkOrdersAreCountedAsMissingAndKeepStateCountsStable() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(2))
+                .andExpect(jsonPath("$.data.events", hasSize(8)))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(8));
+    }
+
+    @Test
+    void legacyStateCodesResolveThroughTheDictionaryWithoutDroppingEvents() throws Exception {
+        when(adapter.load()).thenReturn(fixtureWithLegacyStateCodes());
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
+                .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(0))
+                .andExpect(jsonPath("$.data.summary.cancelledCount").value(2))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(2))
+                .andExpect(jsonPath("$.data.events[0].workOrderStateName").value("已取消"));
+    }
+
+    @Test
+    void averageDurationIgnoresOpenOrdersAndOrdersMissingTimestamps() throws Exception {
+        when(adapter.load()).thenReturn(fixtureWithMixedDurations());
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.handledCount").value(3))
+                .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
+                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("2.00"));
+    }
+
+    @Test
+    void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("workOrderState", "6")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.workOrderState").value(6))
+                .andExpect(jsonPath("$.data.pagination.total").value(2))
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.mapFilter.workOrderState").value(6))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2));
+    }
+
+    @Test
+    void noTruthfulnessOrAccuracyJudgementFieldsExist() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.accuracy").doesNotExist())
+                .andExpect(jsonPath("$.data.summary.truthfulness").doesNotExist())
+                .andExpect(jsonPath("$.data.summary.falsePositiveCount").doesNotExist())
+                .andExpect(jsonPath("$.data.summary.correctAlarmCount").doesNotExist())
+                .andExpect(jsonPath("$.data.accuracy").doesNotExist())
+                .andExpect(jsonPath("$.data.falsePositiveRate").doesNotExist());
+    }
+
+    @Test
+    void invalidFamilyIndustryLevelStateOrWindowReturnsTypedBadRequestBeforeAdapter() throws Exception {
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("eventFamily must be WARNING or ALARM"));
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
+                .andExpect(status().isBadRequest());
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "MANHOLE"))
+                .andExpect(status().isBadRequest());
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("level must be an integer between 1 and 4"));
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("workOrderState", "9"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "workOrderState must be an integer between 1 and 7"));
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "start time and end time must be provided together"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", END)
+                        .param("endTime", START))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("start time must not be after end time"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("page", "0")
+                        .param("pageSize", "1001"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("预警效率分页参数不合法"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void defaultWindowCoversSevenDaysEndingAtBusinessTime() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.endTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.startTime").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.durationTrend", hasSize(169)));
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .header("X-Request-Id", "efficiency-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱预警效率数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("efficiency-500"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitHandlingEfficiencyController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitHandlingEfficiencyControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitHandlingEfficiencyController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(
+                                method.getAnnotation(PreAuthorize.class).value())));
+    }
+
+    private boolean hasRequestMappingAnnotation(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                || method.isAnnotationPresent(GetMapping.class)
+                || method.isAnnotationPresent(PostMapping.class)
+                || method.isAnnotationPresent(PutMapping.class)
+                || method.isAnnotationPresent(PatchMapping.class)
+                || method.isAnnotationPresent(DeleteMapping.class);
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(GetMapping.class)
+                && !method.isAnnotationPresent(PostMapping.class)
+                && !method.isAnnotationPresent(PutMapping.class)
+                && !method.isAnnotationPresent(PatchMapping.class)
+                && !method.isAnnotationPresent(DeleteMapping.class);
+    }
+
+    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"),
+                        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"),
+                        alarm("EA-002", 2, "2026-09-02T11:00:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-9", "供水压力计-009")),
+                Arrays.asList(
+                        link("EW-001", 6, "2026-09-02T08:10:00+08:00", "2026-09-02T10:10:00+08:00"),
+                        link("EW-002", 4, "2026-09-02T08:40:00+08:00", null),
+                        link("EW-003", 5, "2026-09-02T09:20:00+08:00", null),
+                        link("EW-004", 1, "2026-09-02T10:05:00+08:00", null),
+                        link("EW-006", 6, "2026-09-02T11:10:00+08:00", "2026-09-02T12:30:00+08:00"),
+                        link("EW-007", 7, "2026-09-02T11:35:00+08:00", null),
+                        link("EA-001", 6, "2026-09-02T10:20:00+08:00", "2026-09-02T14:20:00+08:00"),
+                        link("EA-002", 4, "2026-09-02T11:00:00+08:00", null)));
+    }
+
+    private static CockpitHandlingEfficiencySnapshot fixtureWithLegacyStateCodes() {
+        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)));
+    }
+
+    private static CockpitHandlingEfficiencySnapshot fixtureWithMixedDurations() {
+        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", 6, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
+                        link("EW-002", 6, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
+                        link("EW-003", 6, "2026-09-02T10:00:00+08:00", null),
+                        link("EW-004", 4, "2026-09-02T11:00:00+08:00", null)));
+    }
+
+    private static java.util.List<CockpitHandlingEfficiencySnapshot.SourceType> types() {
+        return Arrays.asList(
+                new CockpitHandlingEfficiencySnapshot.SourceType(
+                        "GAS_SENSOR", "燃气浓度计", "GAS", "SMART_DEVICE"),
+                new CockpitHandlingEfficiencySnapshot.SourceType(
+                        "WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", "SMART_DEVICE"),
+                new CockpitHandlingEfficiencySnapshot.SourceType(
+                        "SEWAGE_ENV", "污水环境监测", "DRAINAGE", "SMART_DEVICE"));
+    }
+
+    private static CockpitHandlingEfficiencySnapshot.SourceWarning warning(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
+        return new CockpitHandlingEfficiencySnapshot.SourceWarning(
+                no, level, eventTime, typeCode, deviceCode, deviceName);
+    }
+
+    private static CockpitHandlingEfficiencySnapshot.SourceAlarm alarm(
+            String id, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
+        return new CockpitHandlingEfficiencySnapshot.SourceAlarm(
+                id, level, eventTime, typeCode, deviceCode, deviceName);
+    }
+
+    private static CockpitHandlingEfficiencySnapshot.SourceWorkOrderLink link(
+            String eventNo, Integer stateCode, String createTime, String realFinishTime) {
+        return new CockpitHandlingEfficiencySnapshot.SourceWorkOrderLink(
+                eventNo, stateCode, createTime, realFinishTime);
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.event;
+
+public interface CockpitHandlingEfficiencyAdapter {
+    CockpitHandlingEfficiencySnapshot load();
+}

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

@@ -0,0 +1,318 @@
+package com.zksy.cockpit.event;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitHandlingEfficiencyResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String timezone;
+    private final String eventFamily;
+    private final Range range;
+    private final String updatedAt;
+    private final Filters filters;
+    private final Pagination pagination;
+    private final MapFilter mapFilter;
+    private final Summary summary;
+    private final List<WorkOrderStateCount> workOrderStateCounts;
+    private final List<DurationPoint> durationTrend;
+    private final List<EventItem> events;
+
+    public CockpitHandlingEfficiencyResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            String eventFamily,
+            Range range,
+            String updatedAt,
+            Filters filters,
+            Pagination pagination,
+            MapFilter mapFilter,
+            Summary summary,
+            List<WorkOrderStateCount> workOrderStateCounts,
+            List<DurationPoint> durationTrend,
+            List<EventItem> events) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.timezone = timezone;
+        this.eventFamily = eventFamily;
+        this.range = range;
+        this.updatedAt = updatedAt;
+        this.filters = filters;
+        this.pagination = pagination;
+        this.mapFilter = mapFilter;
+        this.summary = summary;
+        this.workOrderStateCounts = workOrderStateCounts;
+        this.durationTrend = durationTrend;
+        this.events = events;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDisplayName() { return displayName; }
+    public String getTimezone() { return timezone; }
+    public String getEventFamily() { return eventFamily; }
+    public Range getRange() { return range; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Filters getFilters() { return filters; }
+    public Pagination getPagination() { return pagination; }
+    public MapFilter getMapFilter() { return mapFilter; }
+    public Summary getSummary() { return summary; }
+    public List<WorkOrderStateCount> getWorkOrderStateCounts() { return workOrderStateCounts; }
+    public List<DurationPoint> getDurationTrend() { return durationTrend; }
+    public List<EventItem> getEvents() { return events; }
+
+    public static class Range {
+        private final String type;
+        private final String startTime;
+        private final String endTime;
+
+        public Range(String type, String startTime, String endTime) {
+            this.type = type;
+            this.startTime = startTime;
+            this.endTime = endTime;
+        }
+
+        public String getType() { return type; }
+        public String getStartTime() { return startTime; }
+        public String getEndTime() { return endTime; }
+    }
+
+    public static class Filters {
+        private final String eventFamily;
+        private final String industry;
+        private final Integer level;
+        private final Integer workOrderState;
+
+        public Filters(String eventFamily, String industry, Integer level, Integer workOrderState) {
+            this.eventFamily = eventFamily;
+            this.industry = industry;
+            this.level = level;
+            this.workOrderState = workOrderState;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getIndustry() { return industry; }
+        public Integer getLevel() { return level; }
+        public Integer getWorkOrderState() { return workOrderState; }
+    }
+
+    public static class Pagination {
+        private final int page;
+        private final int pageSize;
+        private final int total;
+        private final int totalPages;
+        private final boolean hasMore;
+
+        public Pagination(int page, int pageSize, int total, int totalPages, boolean hasMore) {
+            this.page = page;
+            this.pageSize = pageSize;
+            this.total = total;
+            this.totalPages = totalPages;
+            this.hasMore = hasMore;
+        }
+
+        public int getPage() { return page; }
+        public int getPageSize() { return pageSize; }
+        public int getTotal() { return total; }
+        public int getTotalPages() { return totalPages; }
+        public boolean isHasMore() { return hasMore; }
+    }
+
+    public static class MapFilter {
+        private final String eventFamily;
+        private final Integer workOrderState;
+        private final List<String> deviceCodes;
+
+        public MapFilter(String eventFamily, Integer workOrderState, List<String> deviceCodes) {
+            this.eventFamily = eventFamily;
+            this.workOrderState = workOrderState;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public Integer getWorkOrderState() { return workOrderState; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
+
+    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 HandlingEfficiency handlingEfficiency;
+        private final AverageDuration averageDuration;
+
+        public Summary(
+                int totalEvents,
+                int linkedWorkOrderCount,
+                int missingWorkOrderCount,
+                int handledCount,
+                int inProgressCount,
+                int cancelledCount,
+                HandlingEfficiency handlingEfficiency,
+                AverageDuration averageDuration) {
+            this.totalEvents = totalEvents;
+            this.linkedWorkOrderCount = linkedWorkOrderCount;
+            this.missingWorkOrderCount = missingWorkOrderCount;
+            this.handledCount = handledCount;
+            this.inProgressCount = inProgressCount;
+            this.cancelledCount = cancelledCount;
+            this.handlingEfficiency = handlingEfficiency;
+            this.averageDuration = averageDuration;
+        }
+
+        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 HandlingEfficiency getHandlingEfficiency() { return handlingEfficiency; }
+        public AverageDuration getAverageDuration() { return averageDuration; }
+    }
+
+    public static class HandlingEfficiency {
+        private final BigDecimal value;
+        private final int numerator;
+        private final int denominator;
+        private final String state;
+        private final String reason;
+        private final String basis;
+
+        public HandlingEfficiency(
+                BigDecimal value, int numerator, int denominator, String state, String reason, String basis) {
+            this.value = value;
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.state = state;
+            this.reason = reason;
+            this.basis = basis;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+        public String getBasis() { return basis; }
+    }
+
+    public static class AverageDuration {
+        private final BigDecimal value;
+        private final String unit;
+        private final int sampleCount;
+        private final String state;
+        private final String reason;
+
+        public AverageDuration(BigDecimal value, String unit, int sampleCount, String state, String reason) {
+            this.value = value;
+            this.unit = unit;
+            this.sampleCount = sampleCount;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public String getUnit() { return unit; }
+        public int getSampleCount() { return sampleCount; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    public static class WorkOrderStateCount {
+        private final int stateCode;
+        private final String stateName;
+        private final int count;
+
+        public WorkOrderStateCount(int stateCode, String stateName, int count) {
+            this.stateCode = stateCode;
+            this.stateName = stateName;
+            this.count = count;
+        }
+
+        public int 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 AverageDuration averageDuration;
+
+        public DurationPoint(String timeBucket, int eventCount, int handledCount, AverageDuration averageDuration) {
+            this.timeBucket = timeBucket;
+            this.eventCount = eventCount;
+            this.handledCount = handledCount;
+            this.averageDuration = averageDuration;
+        }
+
+        public String getTimeBucket() { return timeBucket; }
+        public int getEventCount() { return eventCount; }
+        public int getHandledCount() { return handledCount; }
+        public AverageDuration getAverageDuration() { return averageDuration; }
+    }
+
+    public static class EventItem {
+        private final String eventNo;
+        private final String deviceCode;
+        private final String deviceName;
+        private final Integer level;
+        private final String levelColor;
+        private final String eventTime;
+        private final Integer workOrderStateCode;
+        private final String workOrderStateName;
+        private final BigDecimal durationHours;
+        private final boolean hasWorkOrder;
+
+        public EventItem(
+                String eventNo,
+                String deviceCode,
+                String deviceName,
+                Integer level,
+                String levelColor,
+                String eventTime,
+                Integer workOrderStateCode,
+                String workOrderStateName,
+                BigDecimal durationHours,
+                boolean hasWorkOrder) {
+            this.eventNo = eventNo;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+            this.level = level;
+            this.levelColor = levelColor;
+            this.eventTime = eventTime;
+            this.workOrderStateCode = workOrderStateCode;
+            this.workOrderStateName = workOrderStateName;
+            this.durationHours = durationHours;
+            this.hasWorkOrder = hasWorkOrder;
+        }
+
+        public String getEventNo() { return eventNo; }
+        public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
+        public Integer getLevel() { return level; }
+        public String getLevelColor() { return levelColor; }
+        public String getEventTime() { return eventTime; }
+        public Integer getWorkOrderStateCode() { return workOrderStateCode; }
+        public String getWorkOrderStateName() { return workOrderStateName; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getDurationHours() { return durationHours; }
+        public boolean isHasWorkOrder() { return hasWorkOrder; }
+    }
+}

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

@@ -0,0 +1,652 @@
+package com.zksy.cockpit.event;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.workorder.CockpitWorkOrderState;
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Read-only aggregation for 预警处置 / 预警效率.
+ *
+ * <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 已结案.
+ */
+@Service
+public class CockpitHandlingEfficiencyService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final String SMART_DEVICE = "SMART_DEVICE";
+    private static final String WARNING = "WARNING";
+    private static final String ALARM = "ALARM";
+    private static final int CLOSED_STATE_CODE = 6;
+    private static final int CANCELLED_STATE_CODE = 7;
+    private static final String EFFICIENCY_BASIS = "CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS";
+    private static final long DEFAULT_WINDOW_DAYS = 7;
+    private static final long MAX_WINDOW_DAYS = 31;
+    private static final int DEFAULT_PAGE_SIZE = 100;
+    private static final int MAX_PAGE_SIZE = 1000;
+    private static final DateTimeFormatter TIMESTAMP =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
+
+    private final CockpitHandlingEfficiencyAdapter adapter;
+    private final Clock clock;
+    private CockpitHandlingEfficiencySnapshot state;
+
+    public CockpitHandlingEfficiencyService(
+            CockpitHandlingEfficiencyAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public synchronized CockpitHandlingEfficiencyResponse load(
+            String requestId,
+            String eventFamily,
+            String industry,
+            String level,
+            String workOrderState,
+            String startTime,
+            String endTime,
+            Integer page,
+            Integer pageSize) {
+        String family = resolveFamily(eventFamily);
+        String selectedIndustry = resolveIndustry(industry);
+        Integer selectedLevel = resolveLevel(level);
+        Integer selectedState = resolveWorkOrderState(workOrderState);
+        TimeWindow window = resolveWindow(startTime, endTime);
+        int safePage = page == null ? 1 : page;
+        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
+        validatePagination(safePage, safePageSize);
+        if (state == null) {
+            CockpitHandlingEfficiencySnapshot loaded = adapter.load();
+            state = loaded == null
+                    ? CockpitHandlingEfficiencySnapshot.empty(businessTime())
+                    : loaded;
+        }
+        return build(requestId, family, selectedIndustry, selectedLevel, selectedState,
+                window, safePage, safePageSize);
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST, message, requestId, businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱预警效率数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private CockpitHandlingEfficiencyResponse build(
+            String requestId,
+            String family,
+            String industry,
+            Integer level,
+            Integer workOrderState,
+            TimeWindow window,
+            int page,
+            int pageSize) {
+        Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types = types(state);
+        Map<String, Link> links = links(state.getWorkOrders());
+        List<EventFact> facts = WARNING.equals(family)
+                ? warningFacts(state, types, industry, level)
+                : alarmFacts(state, types, industry, level);
+        facts = inWindow(facts, window);
+
+        // Scope-wide state counts keep the distribution stable while events filter on it.
+        Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
+        for (CockpitWorkOrderState workOrderStateEntry : CockpitWorkOrderStateDictionary.states()) {
+            stateCounts.put(workOrderStateEntry.getCode(), 0);
+        }
+        int linkedCount = 0;
+        int missingCount = 0;
+        for (EventFact fact : facts) {
+            Link link = linkOf(links, fact);
+            if (link == null) {
+                missingCount++;
+                continue;
+            }
+            linkedCount++;
+            stateCounts.merge(link.stateCode, 1, Integer::sum);
+        }
+
+        List<EventFact> filtered = new ArrayList<>();
+        for (EventFact fact : facts) {
+            Link link = linkOf(links, fact);
+            Integer resolvedState = link == null ? null : link.stateCode;
+            if (workOrderState != null && !workOrderState.equals(resolvedState)) {
+                continue;
+            }
+            filtered.add(fact);
+        }
+        filtered.sort(eventOrdering());
+
+        int total = filtered.size();
+        int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
+        int fromIndex = Math.min((page - 1) * pageSize, total);
+        int toIndex = Math.min(fromIndex + pageSize, total);
+        List<CockpitHandlingEfficiencyResponse.EventItem> items = new ArrayList<>();
+        Set<String> deviceCodes = new LinkedHashSet<>();
+        for (EventFact fact : filtered) {
+            if (!isBlank(fact.deviceCode)) {
+                deviceCodes.add(fact.deviceCode.trim());
+            }
+        }
+        for (EventFact fact : filtered.subList(fromIndex, toIndex)) {
+            Link link = linkOf(links, fact);
+            items.add(new CockpitHandlingEfficiencyResponse.EventItem(
+                    fact.eventNo,
+                    fact.deviceCode,
+                    fact.deviceName,
+                    fact.level,
+                    CockpitEventLevelColor.resolve(fact.level),
+                    fact.eventTime,
+                    link == null ? null : link.stateCode,
+                    link == null ? null : stateName(link.stateCode),
+                    link == null ? null : durationHours(link),
+                    link != null));
+        }
+
+        // Summary metrics are computed over the family/industry/level scope (pre state filter).
+        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() >= 1 && entry.getKey() <= 5) {
+                inProgressCount += entry.getValue();
+            }
+        }
+        int scopeTotal = facts.size();
+
+        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);
+            }
+        }
+
+        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)));
+        }
+
+        return new CockpitHandlingEfficiencyResponse(
+                requestId,
+                "v1",
+                true,
+                "预警效率",
+                "Asia/Shanghai",
+                family,
+                new CockpitHandlingEfficiencyResponse.Range(
+                        "TREND_WINDOW",
+                        timestamp(window.start),
+                        timestamp(window.end)),
+                isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
+                new CockpitHandlingEfficiencyResponse.Filters(family, industry, level, workOrderState),
+                new CockpitHandlingEfficiencyResponse.Pagination(
+                        page, pageSize, total, totalPages, toIndex < total),
+                new CockpitHandlingEfficiencyResponse.MapFilter(
+                        family, workOrderState, sorted(deviceCodes)),
+                new CockpitHandlingEfficiencyResponse.Summary(
+                        scopeTotal,
+                        linkedCount,
+                        missingCount,
+                        handledCount,
+                        inProgressCount,
+                        cancelledCount,
+                        efficiency(handledCount, scopeTotal),
+                        averageDuration(closedDurations)),
+                stateRows,
+                durationTrend(state, family, types, industry, level, links, window),
+                items);
+    }
+
+    private static CockpitHandlingEfficiencyResponse.HandlingEfficiency efficiency(
+            int handled, int total) {
+        if (total <= 0) {
+            return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                    BigDecimal.ZERO.setScale(4), handled, total, "EMPTY", "NO_EVENTS", EFFICIENCY_BASIS);
+        }
+        return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                BigDecimal.valueOf(handled).divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP),
+                handled,
+                total,
+                "AVAILABLE",
+                null,
+                EFFICIENCY_BASIS);
+    }
+
+    private static CockpitHandlingEfficiencyResponse.AverageDuration averageDuration(
+            List<BigDecimal> durations) {
+        if (durations.isEmpty()) {
+            return new CockpitHandlingEfficiencyResponse.AverageDuration(
+                    BigDecimal.ZERO.setScale(2), "HOURS", 0, "EMPTY", "NO_CLOSED_WORK_ORDERS");
+        }
+        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 List<CockpitHandlingEfficiencyResponse.DurationPoint> durationTrend(
+            CockpitHandlingEfficiencySnapshot snapshot,
+            String family,
+            Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
+            String industry,
+            Integer level,
+            Map<String, Link> links,
+            TimeWindow window) {
+        ZonedDateTime first = window.start.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.HOURS);
+        ZonedDateTime last = window.end.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.HOURS);
+        List<String> keys = new ArrayList<>();
+        Map<String, List<BigDecimal>> durations = new LinkedHashMap<>();
+        Map<String, Integer> eventCounts = new LinkedHashMap<>();
+        Map<String, Integer> handledCounts = new LinkedHashMap<>();
+        for (ZonedDateTime bucket = first; !bucket.isAfter(last); bucket = bucket.plusHours(1)) {
+            String key = TIMESTAMP.format(bucket);
+            keys.add(key);
+            durations.put(key, new ArrayList<>());
+            eventCounts.put(key, 0);
+            handledCounts.put(key, 0);
+        }
+        List<EventFact> facts = WARNING.equals(family)
+                ? warningFacts(snapshot, types, industry, level)
+                : alarmFacts(snapshot, types, industry, level);
+        for (EventFact fact : facts) {
+            OffsetDateTime eventTime = parseEventTime(fact.eventTime);
+            if (eventTime == null) {
+                continue;
+            }
+            String key = TIMESTAMP.format(eventTime.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.HOURS));
+            Integer current = eventCounts.get(key);
+            if (current == null) {
+                continue;
+            }
+            eventCounts.put(key, current + 1);
+            Link link = linkOf(links, fact);
+            if (link != null && link.stateCode == CLOSED_STATE_CODE) {
+                handledCounts.put(key, handledCounts.get(key) + 1);
+                BigDecimal duration = durationHours(link);
+                if (duration != null) {
+                    durations.get(key).add(duration);
+                }
+            }
+        }
+        List<CockpitHandlingEfficiencyResponse.DurationPoint> points = new ArrayList<>();
+        for (String key : keys) {
+            points.add(new CockpitHandlingEfficiencyResponse.DurationPoint(
+                    key,
+                    eventCounts.get(key),
+                    handledCounts.get(key),
+                    averageDuration(durations.get(key))));
+        }
+        return points;
+    }
+
+    private List<EventFact> warningFacts(
+            CockpitHandlingEfficiencySnapshot snapshot,
+            Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
+            String industry,
+            Integer level) {
+        List<EventFact> facts = new ArrayList<>();
+        if (snapshot.getWarnings() == null) {
+            return facts;
+        }
+        for (CockpitHandlingEfficiencySnapshot.SourceWarning warning : snapshot.getWarnings()) {
+            if (warning == null
+                    || !inScope(types, warning.getTypeCode(), industry)
+                    || (level != null && !level.equals(warning.getLevel()))) {
+                continue;
+            }
+            facts.add(new EventFact(
+                    warning.getWarningNo(),
+                    warning.getLevel(),
+                    warning.getEventTime(),
+                    warning.getDeviceCode(),
+                    warning.getDeviceName()));
+        }
+        return facts;
+    }
+
+    private List<EventFact> alarmFacts(
+            CockpitHandlingEfficiencySnapshot snapshot,
+            Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
+            String industry,
+            Integer level) {
+        List<EventFact> facts = new ArrayList<>();
+        if (snapshot.getAlarms() == null) {
+            return facts;
+        }
+        for (CockpitHandlingEfficiencySnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
+            if (alarm == null
+                    || !inScope(types, alarm.getTypeCode(), industry)
+                    || (level != null && !level.equals(alarm.getLevel()))) {
+                continue;
+            }
+            facts.add(new EventFact(
+                    alarm.getAlarmId(),
+                    alarm.getLevel(),
+                    alarm.getEventTime(),
+                    alarm.getDeviceCode(),
+                    alarm.getDeviceName()));
+        }
+        return facts;
+    }
+
+    private static boolean inScope(
+            Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
+            String typeCode,
+            String industry) {
+        CockpitHandlingEfficiencySnapshot.SourceType type =
+                isBlank(typeCode) ? null : types.get(typeCode.trim());
+        if (type == null
+                || !SMART_DEVICE.equals(type.getObjectKind())
+                || type.getIndustry() == null
+                || !INDUSTRIES.contains(type.getIndustry())) {
+            return false;
+        }
+        return industry == null || industry.equals(type.getIndustry());
+    }
+
+    /**
+     * Restricts facts to the requested range so the summary, the state counts and
+     * the event list all describe the same window the caller asked about.
+     */
+    private static List<EventFact> inWindow(List<EventFact> facts, TimeWindow window) {
+        List<EventFact> result = new ArrayList<>();
+        for (EventFact fact : facts) {
+            OffsetDateTime eventTime = parseEventTime(fact.eventTime);
+            if (eventTime == null) {
+                continue;
+            }
+            if (eventTime.isBefore(window.start) || eventTime.isAfter(window.end)) {
+                continue;
+            }
+            result.add(fact);
+        }
+        return result;
+    }
+
+    private static Map<String, Link> links(
+            List<CockpitHandlingEfficiencySnapshot.SourceWorkOrderLink> source) {
+        Map<String, Link> result = new LinkedHashMap<>();
+        if (source == null) {
+            return result;
+        }
+        for (CockpitHandlingEfficiencySnapshot.SourceWorkOrderLink link : source) {
+            if (link == null || isBlank(link.getEventNo())) {
+                continue;
+            }
+            result.putIfAbsent(
+                    link.getEventNo().trim(),
+                    new Link(
+                            resolveStateCode(link.getStateCode()),
+                            link.getCreateTime(),
+                            link.getRealFinishTime()));
+        }
+        return result;
+    }
+
+    /**
+     * Migration compatibility: current codes resolve directly, unknown codes fall
+     * back through {@link CockpitWorkOrderStateDictionary#byLegacyCode(Integer)}.
+     */
+    private static int resolveStateCode(Integer stateCode) {
+        CockpitWorkOrderState resolved = CockpitWorkOrderStateDictionary.byCode(stateCode);
+        if (resolved == null) {
+            resolved = CockpitWorkOrderStateDictionary.byLegacyCode(stateCode);
+        }
+        return resolved == null ? CANCELLED_STATE_CODE : resolved.getCode();
+    }
+
+    private static String stateName(Integer stateCode) {
+        CockpitWorkOrderState resolved = CockpitWorkOrderStateDictionary.byCode(stateCode);
+        return resolved == null ? null : resolved.getName();
+    }
+
+    private static Link linkOf(Map<String, Link> links, EventFact fact) {
+        return fact.eventNo == null ? null : links.get(fact.eventNo.trim());
+    }
+
+    private static BigDecimal durationHours(Link link) {
+        OffsetDateTime created = parseEventTime(link.createTime);
+        OffsetDateTime finished = parseEventTime(link.realFinishTime);
+        if (created == null || finished == null || finished.isBefore(created)) {
+            return null;
+        }
+        long minutes = Duration.between(created, finished).toMinutes();
+        return BigDecimal.valueOf(minutes)
+                .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
+    }
+
+    private static Comparator<EventFact> eventOrdering() {
+        return Comparator
+                .comparing(EventFact::getEventTime, Comparator.nullsLast(Comparator.reverseOrder()))
+                .thenComparing(EventFact::getEventNo, Comparator.nullsLast(String::compareTo));
+    }
+
+    private static Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types(
+            CockpitHandlingEfficiencySnapshot snapshot) {
+        Map<String, CockpitHandlingEfficiencySnapshot.SourceType> result = new LinkedHashMap<>();
+        if (snapshot.getTypes() != null) {
+            for (CockpitHandlingEfficiencySnapshot.SourceType type : snapshot.getTypes()) {
+                if (type != null && !isBlank(type.getCode())) {
+                    result.putIfAbsent(type.getCode().trim(), type);
+                }
+            }
+        }
+        return result;
+    }
+
+    private static String resolveFamily(String eventFamily) {
+        if (isBlank(eventFamily)) {
+            throw new CockpitInvalidRequestException("eventFamily must be WARNING or ALARM");
+        }
+        String resolved = eventFamily.trim();
+        if (!WARNING.equals(resolved) && !ALARM.equals(resolved)) {
+            throw new CockpitInvalidRequestException("eventFamily must be WARNING or ALARM");
+        }
+        return resolved;
+    }
+
+    private static String resolveIndustry(String industry) {
+        if (isBlank(industry)) {
+            return null;
+        }
+        String resolved = industry.trim();
+        if (!INDUSTRIES.contains(resolved)) {
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+        }
+        return resolved;
+    }
+
+    private static Integer resolveLevel(String level) {
+        if (isBlank(level)) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(level.trim());
+            if (resolved < 1 || resolved > 4) {
+                throw new CockpitInvalidRequestException("level must be an integer between 1 and 4");
+            }
+            return resolved;
+        } catch (NumberFormatException exception) {
+            throw new CockpitInvalidRequestException("level must be an integer between 1 and 4");
+        }
+    }
+
+    private static Integer resolveWorkOrderState(String workOrderState) {
+        if (isBlank(workOrderState)) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(workOrderState.trim());
+            if (CockpitWorkOrderStateDictionary.byCode(resolved) == null) {
+                throw new CockpitInvalidRequestException(
+                        "workOrderState must be an integer between 1 and 7");
+            }
+            return resolved;
+        } catch (NumberFormatException exception) {
+            throw new CockpitInvalidRequestException(
+                    "workOrderState must be an integer between 1 and 7");
+        }
+    }
+
+    private static void validatePagination(int page, int pageSize) {
+        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new CockpitInvalidRequestException("预警效率分页参数不合法");
+        }
+    }
+
+    private TimeWindow resolveWindow(String startTime, String endTime) {
+        boolean hasStart = !isBlank(startTime);
+        boolean hasEnd = !isBlank(endTime);
+        if (hasStart != hasEnd) {
+            throw new CockpitInvalidRequestException("start time and end time must be provided together");
+        }
+        if (!hasStart) {
+            OffsetDateTime end = OffsetDateTime.now(clock)
+                    .atZoneSameInstant(BUSINESS_ZONE)
+                    .toOffsetDateTime();
+            return new TimeWindow(end.minusDays(DEFAULT_WINDOW_DAYS), end);
+        }
+        OffsetDateTime start = parseTime(startTime, "start time");
+        OffsetDateTime end = parseTime(endTime, "end time");
+        if (start.isAfter(end)) {
+            throw new CockpitInvalidRequestException("start time must not be after end time");
+        }
+        if (Duration.between(start, end).toDays() > MAX_WINDOW_DAYS) {
+            throw new CockpitInvalidRequestException("efficiency window must not exceed 31 days");
+        }
+        return new TimeWindow(start, end);
+    }
+
+    private static OffsetDateTime parseTime(String value, String fieldName) {
+        if (isBlank(value)) {
+            throw new CockpitInvalidRequestException(fieldName + " is required");
+        }
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid " + fieldName);
+        }
+    }
+
+    private static OffsetDateTime parseEventTime(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            return null;
+        }
+    }
+
+    private static List<String> sorted(Set<String> values) {
+        List<String> result = new ArrayList<>(values);
+        Collections.sort(result, Comparator.nullsLast(String::compareTo));
+        return result;
+    }
+
+    private String businessTime() {
+        return TIMESTAMP.format(OffsetDateTime.now(clock)
+                .atZoneSameInstant(BUSINESS_ZONE)
+                .toOffsetDateTime());
+    }
+
+    private static String timestamp(OffsetDateTime value) {
+        return TIMESTAMP.format(value.atZoneSameInstant(BUSINESS_ZONE));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static class TimeWindow {
+        private final OffsetDateTime start;
+        private final OffsetDateTime end;
+
+        private TimeWindow(OffsetDateTime start, OffsetDateTime end) {
+            this.start = start;
+            this.end = end;
+        }
+    }
+
+    private static class EventFact {
+        private final String eventNo;
+        private final Integer level;
+        private final String eventTime;
+        private final String deviceCode;
+        private final String deviceName;
+
+        private EventFact(String eventNo, Integer level, String eventTime, String deviceCode, String deviceName) {
+            this.eventNo = eventNo;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+        }
+
+        private String getEventNo() {
+            return eventNo;
+        }
+
+        private String getEventTime() {
+            return eventTime;
+        }
+    }
+
+    private static class Link {
+        private final int stateCode;
+        private final String createTime;
+        private final String realFinishTime;
+
+        private Link(int stateCode, String createTime, String realFinishTime) {
+            this.stateCode = stateCode;
+            this.createTime = createTime;
+            this.realFinishTime = realFinishTime;
+        }
+    }
+}

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

@@ -0,0 +1,151 @@
+package com.zksy.cockpit.event;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Read snapshot for 预警处置 / 预警效率.
+ *
+ * <p>Delivers both event families separately plus their work-order links. Links
+ * are nullable per event: an event without a link is counted as missing while
+ * keeping the event in the result.
+ */
+public class CockpitHandlingEfficiencySnapshot {
+    private final String updatedAt;
+    private final List<SourceType> types;
+    private final List<SourceWarning> warnings;
+    private final List<SourceAlarm> alarms;
+    private final List<SourceWorkOrderLink> workOrders;
+
+    public CockpitHandlingEfficiencySnapshot(
+            String updatedAt,
+            List<SourceType> types,
+            List<SourceWarning> warnings,
+            List<SourceAlarm> alarms,
+            List<SourceWorkOrderLink> workOrders) {
+        this.updatedAt = updatedAt;
+        this.types = types;
+        this.warnings = warnings;
+        this.alarms = alarms;
+        this.workOrders = workOrders;
+    }
+
+    public static CockpitHandlingEfficiencySnapshot empty(String updatedAt) {
+        return new CockpitHandlingEfficiencySnapshot(
+                updatedAt,
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+
+    public String getUpdatedAt() { return updatedAt; }
+    public List<SourceType> getTypes() { return types; }
+    public List<SourceWarning> getWarnings() { return warnings; }
+    public List<SourceAlarm> getAlarms() { return alarms; }
+    public List<SourceWorkOrderLink> getWorkOrders() { return workOrders; }
+
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+        public String getObjectKind() { return objectKind; }
+    }
+
+    public static class SourceWarning {
+        private final String warningNo;
+        private final Integer level;
+        private final String eventTime;
+        private final String typeCode;
+        private final String deviceCode;
+        private final String deviceName;
+
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String deviceName) {
+            this.warningNo = warningNo;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+        }
+
+        public String getWarningNo() { return warningNo; }
+        public Integer getLevel() { return level; }
+        public String getEventTime() { return eventTime; }
+        public String getTypeCode() { return typeCode; }
+        public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
+    }
+
+    public static class SourceAlarm {
+        private final String alarmId;
+        private final Integer level;
+        private final String eventTime;
+        private final String typeCode;
+        private final String deviceCode;
+        private final String deviceName;
+
+        public SourceAlarm(
+                String alarmId,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String deviceName) {
+            this.alarmId = alarmId;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+        }
+
+        public String getAlarmId() { return alarmId; }
+        public Integer getLevel() { return level; }
+        public String getEventTime() { return eventTime; }
+        public String getTypeCode() { return typeCode; }
+        public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
+    }
+
+    public static class SourceWorkOrderLink {
+        private final String eventNo;
+        private final Integer stateCode;
+        private final String createTime;
+        private final String realFinishTime;
+
+        public SourceWorkOrderLink(
+                String eventNo,
+                Integer stateCode,
+                String createTime,
+                String realFinishTime) {
+            this.eventNo = eventNo;
+            this.stateCode = stateCode;
+            this.createTime = createTime;
+            this.realFinishTime = realFinishTime;
+        }
+
+        public String getEventNo() { return eventNo; }
+        public Integer getStateCode() { return stateCode; }
+        public String getCreateTime() { return createTime; }
+        public String getRealFinishTime() { return realFinishTime; }
+    }
+}

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

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.event;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@Service
+public class EmptyCockpitHandlingEfficiencyAdapter implements CockpitHandlingEfficiencyAdapter {
+    private final Clock clock;
+
+    public EmptyCockpitHandlingEfficiencyAdapter(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitHandlingEfficiencySnapshot load() {
+        return CockpitHandlingEfficiencySnapshot.empty(businessTime());
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}