Prechádzať zdrojové kódy

feat(cockpit): add infrastructure management information (#34)

Kazerin 1 mesiac pred
rodič
commit
520f4678e7

+ 248 - 0
docs/cockpit/infrastructure-management-information-demo.html

@@ -0,0 +1,248 @@
+<!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:#172033; --muted:#667085; --line:#d9dee7; --panel:#fff; --page:#f3f5f8;
+            --accent:#1570ef; --ok:#067647; --warn:#b54708; --idle:#98a2b3; }
+    * { 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; align-items:center; justify-content:space-between;
+             padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:18px; }
+    .meta { display:flex; flex-wrap:wrap; gap:14px; color:var(--muted); }
+    .meta b { color:var(--ink); font-weight:600; }
+    button { height:30px; min-width:76px; padding:0 12px; border:1px solid #d0d5dd; border-radius:4px;
+             background:var(--panel); color:var(--ink); cursor:pointer; font-size:13px; }
+    button:hover { border-color:var(--accent); color:var(--accent); }
+    main { display:grid; grid-template-columns:minmax(360px,440px) minmax(0,1fr); min-height:calc(100vh - 59px); }
+    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-height:0; }
+    h2 { margin:0 0 10px; font-size:14px; font-weight:600; }
+    h2.mid { margin-top:20px; }
+    .kpis { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-bottom:6px; }
+    .kpi { border:1px solid var(--line); border-radius:6px; padding:10px 12px; }
+    .kpi span { display:block; color:var(--muted); font-size:12px; }
+    .kpi b { display:block; margin-top:4px; font-size:20px; font-weight:600; }
+    table { width:100%; border-collapse:collapse; }
+    th { text-align:left; font-size:12px; color:var(--muted); font-weight:600;
+         padding:6px 8px; border-bottom:1px solid var(--line); }
+    th.num, td.num { text-align:right; }
+    td { padding:8px; border-bottom:1px solid #eef1f5; }
+    tr.unit { cursor:pointer; }
+    tr.unit:hover td { background:#f8fafc; }
+    tr.unit.sel td { background:#eff6ff; }
+    tr.unit.unmaintained .name { color:var(--warn); }
+    .name { font-weight:600; }
+    .code { display:block; color:var(--muted); font-size:11px; font-weight:400; }
+    .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; line-height:18px;
+             border:1px solid currentColor; }
+    .b-OWNED { color:var(--ok); }
+    .b-LEASED { color:var(--accent); }
+    .b-ENTRUSTED { color:var(--warn); }
+    .b-UNKNOWN { color:var(--idle); }
+    .dist { display:grid; gap:6px; }
+    .dist .row { display:grid; grid-template-columns:92px minmax(0,1fr) 78px; gap:8px; align-items:center; font-size:12px; }
+    .track { height:8px; background:#eef1f5; }
+    .fill { height:100%; background:var(--accent); }
+    .toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px;
+               padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
+    .map { position:relative; background:#eef2f6;
+           background-image:linear-gradient(#dde3ea 1px,transparent 1px),linear-gradient(90deg,#dde3ea 1px,transparent 1px);
+           background-size:64px 64px; }
+    .point { position:absolute; width:12px; height:12px; margin:-6px 0 0 -6px; border-radius:50%;
+             background:#9fb3c8; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.25); }
+    .point.facility { border-radius:3px; }
+    .point.in-scope { background:var(--accent); }
+    .point.unmaintained { background:var(--warn); }
+    .plabel { position:absolute; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.93);
+              border:1px solid var(--line); font-size:11px; white-space:nowrap; color:#344054; }
+    .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
+    .caption b { color:var(--ink); }
+    .legend { position:absolute; left:14px; bottom:14px; padding:6px 10px; border-radius:4px;
+              background:rgba(255,255,255,.92); border:1px solid var(--line); color:var(--muted); font-size:12px; }
+    @media (max-width:940px) { main { grid-template-columns:1fr; } .map { min-height:380px; } }
+  </style>
+</head>
+<body>
+<header>
+  <h1>基础设施 / 管理信息</h1>
+  <div class="meta">
+    <span>单位 <b id="orgCount">0</b></span>
+    <span>设施 <b id="facilityCount">0</b></span>
+    <span>智能设备 <b id="equipmentCount">0</b></span>
+    <span>未维护 <b id="unmaintainedCount">0</b></span>
+    <span>更新 <b id="updatedAt">--</b></span>
+  </div>
+  <button id="refresh" type="button">手动刷新</button>
+</header>
+
+<main>
+  <aside>
+    <h2>管理单位</h2>
+    <table>
+      <thead>
+        <tr><th>单位</th><th class="num">设施</th><th class="num">智能设备</th></tr>
+      </thead>
+      <tbody id="units"></tbody>
+    </table>
+    <h2 class="mid">权属分布</h2>
+    <div class="dist" id="ownership"></div>
+  </aside>
+
+  <section>
+    <div class="toolbar">
+      <strong id="mapTitle">中央一张图</strong>
+      <span class="meta">选择单位可筛选地图点位</span>
+    </div>
+    <div class="map" id="map">
+      <div class="legend">方块=设施 · 圆点=智能设备 · 蓝=当前单位 · 橙=未维护</div>
+    </div>
+    <div class="caption" id="caption">未选择单位,展示全部管理权属点位</div>
+  </section>
+</main>
+
+<script>
+  const fixture = {
+    updatedAt: "2026-09-02T09:59:50+08:00",
+    units: [
+      { code: "ORG-WS-A", name: "供水一公司", industry: "WATER_SUPPLY", unmaintained: false },
+      { code: "ORG-GAS-A", name: "燃气公司", industry: "GAS", unmaintained: false },
+      { code: "ORG-WS-B", name: "供水二公司", industry: "WATER_SUPPLY", unmaintained: false },
+      { code: "UNMAINTAINED", name: "未维护", industry: "DRAINAGE", unmaintained: true }
+    ],
+    objects: [
+      { code: "F-WS-001", kind: "FACILITY", unit: "ORG-WS-A", ownership: "OWNED", x: 22, y: 30 },
+      { code: "F-WS-002", kind: "FACILITY", unit: "ORG-WS-A", ownership: "OWNED", x: 34, y: 44 },
+      { code: "E-WS-001", kind: "SMART_DEVICE", unit: "ORG-WS-A", ownership: "LEASED", x: 43, y: 25 },
+      { code: "F-WS-003", kind: "FACILITY", unit: "ORG-WS-B", ownership: "ENTRUSTED", x: 58, y: 36 },
+      { code: "E-WS-002", kind: "SMART_DEVICE", unit: "ORG-WS-B", ownership: "UNKNOWN", x: 66, y: 52 },
+      { code: "F-GAS-001", kind: "FACILITY", unit: "ORG-GAS-A", ownership: "OWNED", x: 30, y: 66 },
+      { code: "E-GAS-001", kind: "SMART_DEVICE", unit: "ORG-GAS-A", ownership: "OWNED", x: 44, y: 74 },
+      { code: "F-DR-001", kind: "FACILITY", unit: "UNMAINTAINED", ownership: "OWNED", x: 78, y: 30 },
+      { code: "E-DR-001", kind: "SMART_DEVICE", unit: "UNMAINTAINED", ownership: "UNKNOWN", x: 86, y: 58 }
+    ]
+  };
+
+  const OWNERSHIP_TYPES = ["OWNED", "LEASED", "ENTRUSTED", "UNKNOWN"];
+  let state = { unit: null, lastRefresh: null };
+
+  function objectsFor(unitCode) {
+    return unitCode ? fixture.objects.filter(o => o.unit === unitCode) : fixture.objects.slice();
+  }
+
+  function countsFor(unitCode) {
+    const list = objectsFor(unitCode);
+    return {
+      facility: list.filter(o => o.kind === "FACILITY").length,
+      equipment: list.filter(o => o.kind === "SMART_DEVICE").length
+    };
+  }
+
+  function ownershipDistribution(unitCode) {
+    return OWNERSHIP_TYPES.map(type => {
+      const list = objectsFor(unitCode).filter(o => o.ownership === type);
+      return {
+        type: type,
+        facility: list.filter(o => o.kind === "FACILITY").length,
+        equipment: list.filter(o => o.kind === "SMART_DEVICE").length,
+        total: list.length
+      };
+    }).filter(item => item.total > 0)
+      .sort((a, b) => b.total - a.total || (a.type < b.type ? -1 : 1));
+  }
+
+  function renderHeader() {
+    const counts = countsFor(state.unit);
+    document.getElementById("orgCount").textContent = state.unit ? 1 : fixture.units.length;
+    document.getElementById("facilityCount").textContent = counts.facility;
+    document.getElementById("equipmentCount").textContent = counts.equipment;
+    document.getElementById("unmaintainedCount").textContent = objectsFor("UNMAINTAINED").length;
+    document.getElementById("updatedAt").textContent = state.lastRefresh || fixture.updatedAt;
+  }
+
+  function renderUnits() {
+    const rows = fixture.units.map(unit => {
+      const counts = countsFor(unit.code);
+      const selected = state.unit === unit.code ? " sel" : "";
+      const flag = unit.unmaintained ? " unmaintained" : "";
+      return `<tr class="unit${selected}${flag}" data-code="${unit.code}">
+        <td><span class="name">${unit.name}</span><span class="code">${unit.code} · ${unit.industry}</span></td>
+        <td class="num">${counts.facility}</td>
+        <td class="num">${counts.equipment}</td>
+      </tr>`;
+    }).join("");
+    const allSelected = state.unit === null ? " sel" : "";
+    document.getElementById("units").innerHTML =
+      `<tr class="unit${allSelected}" data-code=""><td><span class="name">全部单位</span><span class="code">ALL</span></td>
+         <td class="num">${fixture.objects.filter(o => o.kind === "FACILITY").length}</td>
+         <td class="num">${fixture.objects.filter(o => o.kind === "SMART_DEVICE").length}</td></tr>` + rows;
+  }
+
+  function renderOwnership() {
+    const distribution = ownershipDistribution(state.unit);
+    const max = distribution.length ? distribution[0].total : 1;
+    document.getElementById("ownership").innerHTML = distribution.map(item =>
+      `<div class="row">
+         <span class="badge b-${item.type}">${item.type}</span>
+         <div class="track"><div class="fill" style="width:${item.total / max * 100}%"></div></div>
+         <span>${item.facility} / ${item.equipment}</span>
+       </div>`).join("") || '<span class="code">无数据</span>';
+  }
+
+  function renderMap() {
+    const map = document.getElementById("map");
+    map.querySelectorAll(".point,.plabel").forEach(node => node.remove());
+    fixture.objects
+      .filter(obj => state.unit === null || obj.unit === state.unit)
+      .forEach(obj => {
+      const point = document.createElement("div");
+      point.className = "point " + (obj.kind === "FACILITY" ? "facility " : "")
+        + (obj.unit === "UNMAINTAINED" ? "unmaintained" : "in-scope");
+      point.style.left = obj.x + "%";
+      point.style.top = obj.y + "%";
+      point.title = obj.code + " · " + obj.unit + " · " + obj.ownership;
+      map.appendChild(point);
+      const label = document.createElement("div");
+      label.className = "plabel";
+      label.style.left = "calc(" + obj.x + "% + 10px)";
+      label.style.top = "calc(" + obj.y + "% - 9px)";
+      label.textContent = obj.code;
+      map.appendChild(label);
+    });
+    const unit = fixture.units.find(u => u.code === state.unit);
+    document.getElementById("mapTitle").textContent = "中央一张图 · " + (unit ? unit.name : "全部单位");
+    document.getElementById("caption").innerHTML = unit
+      ? "地图筛选 <b>" + unit.code + "</b> · 设施 <b>" + countsFor(unit.code).facility
+        + "</b> · 智能设备 <b>" + countsFor(unit.code).equipment + "</b>"
+      : "未选择单位,展示全部管理权属点位 · 橙色为未维护桶";
+  }
+
+  function render() {
+    renderHeader();
+    renderUnits();
+    renderOwnership();
+    renderMap();
+  }
+
+  document.getElementById("units").addEventListener("click", event => {
+    const row = event.target.closest("tr.unit");
+    if (!row) return;
+    const code = row.dataset.code;
+    state.unit = code === "" || code === state.unit ? null : code;
+    render();
+  });
+
+  document.getElementById("refresh").addEventListener("click", () => {
+    state.lastRefresh = new Date().toISOString();
+    state.unit = null;
+    render();
+  });
+
+  render();
+</script>
+</body>
+</html>

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

@@ -0,0 +1,66 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationService;
+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 CockpitInfrastructureManagementInformationController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitInfrastructureManagementInformationService managementInformationService;
+
+    public CockpitInfrastructureManagementInformationController(
+            CockpitInfrastructureManagementInformationService managementInformationService) {
+        this.managementInformationService = managementInformationService;
+    }
+
+    @GetMapping("/api/cockpit/v1/infrastructure/management-information")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> managementInformation(
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "organizationCode", required = false) String organizationCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                managementInformationService.load(
+                        resolveRequestId(requestId), industry, organizationCode)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, managementInformationService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, managementInformationService.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();
+    }
+}

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

@@ -0,0 +1,304 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationSnapshot;
+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.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+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 CockpitInfrastructureManagementInformationControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/management-information";
+
+    private CockpitInfrastructureManagementInformationAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitInfrastructureManagementInformationAdapter.class);
+        CockpitInfrastructureManagementInformationService service =
+                new CockpitInfrastructureManagementInformationService(
+                        adapter,
+                        Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitInfrastructureManagementInformationController(service))
+                .build();
+    }
+
+    @Test
+    void managementInformationGroupsOrganizationsAndOwnershipDistribution() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "management-information-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("management-information-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.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("READY"))
+                .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value(nullValue()))
+                .andExpect(jsonPath("$.data.totals.organizationCount").value(4))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(4))
+                .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2))
+                .andExpect(jsonPath("$.data.organizations", hasSize(4)))
+                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
+                .andExpect(jsonPath("$.data.organizations[0].organizationName").value("供水一公司"))
+                .andExpect(jsonPath("$.data.organizations[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(false))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution", hasSize(2)))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].ownershipType").value("OWNED"))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].equipmentCount").value(0))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].total").value(2))
+                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[1].ownershipType").value("LEASED"))
+                .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-GAS-A"))
+                .andExpect(jsonPath("$.data.organizations[2].organizationCode").value("ORG-WS-B"))
+                .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[0].ownershipType").value("ENTRUSTED"))
+                .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(4)))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].ownershipType").value("OWNED"))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].facilityCount").value(4))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(5))
+                .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
+                .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
+                .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(4)))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
+                .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.unknownObjectKindCount").value(1));
+    }
+
+    @Test
+    void missingOrganizationFallsIntoExplicitUnmaintainedBucket() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.organizations[3].organizationCode").value("UNMAINTAINED"))
+                .andExpect(jsonPath("$.data.organizations[3].organizationName").value("未维护"))
+                .andExpect(jsonPath("$.data.organizations[3].unmaintained").value(true))
+                .andExpect(jsonPath("$.data.organizations[3].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.organizations[3].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.organizations[3].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[0].ownershipType").value("OWNED"))
+                .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2));
+    }
+
+    @Test
+    void duplicateRelationsAreCountedOnceAndReportedAsExcluded() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1));
+    }
+
+    @Test
+    void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
+    }
+
+    @Test
+    void industryFilterRestrictsOrganizationsToTheFormalIndustry() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.organizations", hasSize(2)))
+                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
+                .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-WS-B"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(2));
+    }
+
+    @Test
+    void organizationCodeFilterSelectsSingleUnit() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-GAS-A"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value("ORG-GAS-A"))
+                .andExpect(jsonPath("$.data.organizations", hasSize(1)))
+                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-GAS-A"))
+                .andExpect(jsonPath("$.data.organizations[0].organizationName").value("燃气公司"))
+                .andExpect(jsonPath("$.data.organizations[0].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-GAS-A"));
+    }
+
+    @Test
+    void unmaintainedCodeFilterSelectsOnlyTheUnmaintainedBucket() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "UNMAINTAINED"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.organizations", hasSize(1)))
+                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("UNMAINTAINED"))
+                .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(true))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1));
+    }
+
+    @Test
+    void unknownOrganizationCodeReturnsExplicitEmptyResult() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-MISSING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.organizations", hasSize(0)))
+                .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(0))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(0)))
+                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+    }
+
+    @Test
+    void emptySnapshotReturnsExplicitEmptyState() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitInfrastructureManagementInformationSnapshot(
+                "2026-09-02T09:59:50+08:00", Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.organizations", hasSize(0)))
+                .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(0)))
+                .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
+                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+    }
+
+    @Test
+    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "management-information-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("management-information-500"));
+    }
+
+    @Test
+    void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
+        for (java.lang.reflect.Method method
+                : CockpitInfrastructureManagementInformationController.class.getDeclaredMethods()) {
+            if (method.isAnnotationPresent(GetMapping.class)) {
+                assertTrue(!method.isAnnotationPresent(PostMapping.class)
+                        && !method.isAnnotationPresent(PutMapping.class)
+                        && !method.isAnnotationPresent(PatchMapping.class)
+                        && !method.isAnnotationPresent(DeleteMapping.class));
+            }
+        }
+
+        assertTrue(Arrays.stream(
+                        CockpitInfrastructureManagementInformationController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(
+                                method.getAnnotation(PreAuthorize.class).value())));
+    }
+
+    private static CockpitInfrastructureManagementInformationSnapshot fixture() {
+        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations = new ArrayList<>();
+        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
+        relations.add(relation("F-WS-002", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
+        relations.add(relation("E-WS-001", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "LEASED"));
+        relations.add(relation("F-WS-003", "FACILITY", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", "ENTRUSTED"));
+        relations.add(relation("E-WS-002", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", null));
+        relations.add(relation("F-GAS-001", "FACILITY", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
+        relations.add(relation("E-GAS-001", "SMART_DEVICE", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
+        relations.add(relation("F-DR-001", "FACILITY", "DRAINAGE", null, null, "OWNED"));
+        relations.add(relation("E-DR-001", "SMART_DEVICE", "DRAINAGE", "   ", null, null));
+        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
+        relations.add(relation("F-MH-001", "FACILITY", "MANHOLE", "ORG-MH-A", "井盖公司", "OWNED"));
+        relations.add(relation("E-XX-001", "SMART_DEVICE", null, "ORG-WS-A", "供水一公司", "OWNED"));
+        relations.add(relation("E-XX-002", "SENSOR", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
+        return new CockpitInfrastructureManagementInformationSnapshot(
+                "2026-09-02T09:59:50+08:00", relations);
+    }
+
+    private static CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation(
+            String objectCode,
+            String objectKind,
+            String industry,
+            String organizationCode,
+            String organizationName,
+            String ownershipType) {
+        return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
+                objectCode, objectKind, industry, organizationCode, organizationName, ownershipType);
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.infrastructure;
+
+public interface CockpitInfrastructureManagementInformationAdapter {
+    CockpitInfrastructureManagementInformationSnapshot load();
+}

+ 206 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationResponse.java

@@ -0,0 +1,206 @@
+package com.zksy.cockpit.infrastructure;
+
+import java.util.List;
+
+public class CockpitInfrastructureManagementInformationResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String timezone;
+    private final String updatedAt;
+    private final Freshness freshness;
+    private final SelectedFilters selectedFilters;
+    private final Totals totals;
+    private final List<Organization> organizations;
+    private final List<Ownership> ownershipDistribution;
+    private final MapFilter mapFilter;
+    private final Excluded excluded;
+
+    public CockpitInfrastructureManagementInformationResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            SelectedFilters selectedFilters,
+            Totals totals,
+            List<Organization> organizations,
+            List<Ownership> ownershipDistribution,
+            MapFilter mapFilter,
+            Excluded excluded) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.timezone = timezone;
+        this.updatedAt = updatedAt;
+        this.freshness = freshness;
+        this.selectedFilters = selectedFilters;
+        this.totals = totals;
+        this.organizations = organizations;
+        this.ownershipDistribution = ownershipDistribution;
+        this.mapFilter = mapFilter;
+        this.excluded = excluded;
+    }
+
+    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 getUpdatedAt() { return updatedAt; }
+    public Freshness getFreshness() { return freshness; }
+    public SelectedFilters getSelectedFilters() { return selectedFilters; }
+    public Totals getTotals() { return totals; }
+    public List<Organization> getOrganizations() { return organizations; }
+    public List<Ownership> getOwnershipDistribution() { return ownershipDistribution; }
+    public MapFilter getMapFilter() { return mapFilter; }
+    public Excluded getExcluded() { return excluded; }
+
+    public static class Freshness {
+        private final String status;
+        private final String reason;
+        private final String updatedAt;
+        private final String serverUpdatedAt;
+
+        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
+            this.status = status;
+            this.reason = reason;
+            this.updatedAt = updatedAt;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public String getUpdatedAt() { return updatedAt; }
+        public String getServerUpdatedAt() { return serverUpdatedAt; }
+    }
+
+    public static class SelectedFilters {
+        private final String industry;
+        private final String organizationCode;
+
+        public SelectedFilters(String industry, String organizationCode) {
+            this.industry = industry;
+            this.organizationCode = organizationCode;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getOrganizationCode() { return organizationCode; }
+    }
+
+    public static class Totals {
+        private final int organizationCount;
+        private final int facilityCount;
+        private final int equipmentCount;
+        private final int unmaintainedCount;
+
+        public Totals(int organizationCount, int facilityCount, int equipmentCount, int unmaintainedCount) {
+            this.organizationCount = organizationCount;
+            this.facilityCount = facilityCount;
+            this.equipmentCount = equipmentCount;
+            this.unmaintainedCount = unmaintainedCount;
+        }
+
+        public int getOrganizationCount() { return organizationCount; }
+        public int getFacilityCount() { return facilityCount; }
+        public int getEquipmentCount() { return equipmentCount; }
+        public int getUnmaintainedCount() { return unmaintainedCount; }
+    }
+
+    public static class Organization {
+        private final String organizationCode;
+        private final String organizationName;
+        private final String industry;
+        private final int facilityCount;
+        private final int equipmentCount;
+        private final boolean unmaintained;
+        private final List<Ownership> ownershipDistribution;
+
+        public Organization(
+                String organizationCode,
+                String organizationName,
+                String industry,
+                int facilityCount,
+                int equipmentCount,
+                boolean unmaintained,
+                List<Ownership> ownershipDistribution) {
+            this.organizationCode = organizationCode;
+            this.organizationName = organizationName;
+            this.industry = industry;
+            this.facilityCount = facilityCount;
+            this.equipmentCount = equipmentCount;
+            this.unmaintained = unmaintained;
+            this.ownershipDistribution = ownershipDistribution;
+        }
+
+        public String getOrganizationCode() { return organizationCode; }
+        public String getOrganizationName() { return organizationName; }
+        public String getIndustry() { return industry; }
+        public int getFacilityCount() { return facilityCount; }
+        public int getEquipmentCount() { return equipmentCount; }
+        public boolean isUnmaintained() { return unmaintained; }
+        public List<Ownership> getOwnershipDistribution() { return ownershipDistribution; }
+    }
+
+    public static class Ownership {
+        private final String ownershipType;
+        private final int facilityCount;
+        private final int equipmentCount;
+        private final int total;
+
+        public Ownership(String ownershipType, int facilityCount, int equipmentCount, int total) {
+            this.ownershipType = ownershipType;
+            this.facilityCount = facilityCount;
+            this.equipmentCount = equipmentCount;
+            this.total = total;
+        }
+
+        public String getOwnershipType() { return ownershipType; }
+        public int getFacilityCount() { return facilityCount; }
+        public int getEquipmentCount() { return equipmentCount; }
+        public int getTotal() { return total; }
+    }
+
+    public static class MapFilter {
+        private final String industry;
+        private final String organizationCode;
+        private final List<String> organizationCodes;
+
+        public MapFilter(String industry, String organizationCode, List<String> organizationCodes) {
+            this.industry = industry;
+            this.organizationCode = organizationCode;
+            this.organizationCodes = organizationCodes;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getOrganizationCode() { return organizationCode; }
+        public List<String> getOrganizationCodes() { return organizationCodes; }
+    }
+
+    public static class Excluded {
+        private final int unknownIndustryCount;
+        private final int nonFormalIndustryCount;
+        private final int duplicateRelationCount;
+        private final int unknownObjectKindCount;
+
+        public Excluded(
+                int unknownIndustryCount,
+                int nonFormalIndustryCount,
+                int duplicateRelationCount,
+                int unknownObjectKindCount) {
+            this.unknownIndustryCount = unknownIndustryCount;
+            this.nonFormalIndustryCount = nonFormalIndustryCount;
+            this.duplicateRelationCount = duplicateRelationCount;
+            this.unknownObjectKindCount = unknownObjectKindCount;
+        }
+
+        public int getUnknownIndustryCount() { return unknownIndustryCount; }
+        public int getNonFormalIndustryCount() { return nonFormalIndustryCount; }
+        public int getDuplicateRelationCount() { return duplicateRelationCount; }
+        public int getUnknownObjectKindCount() { return unknownObjectKindCount; }
+    }
+}

+ 292 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationService.java

@@ -0,0 +1,292 @@
+package com.zksy.cockpit.infrastructure;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+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 management-ownership view: which unit manages which facilities and smart devices.
+ */
+@Service
+public class CockpitInfrastructureManagementInformationService {
+    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 FACILITY = "FACILITY";
+    private static final String SMART_DEVICE = "SMART_DEVICE";
+    private static final String UNMAINTAINED_CODE = "UNMAINTAINED";
+    private static final String UNMAINTAINED_NAME = "未维护";
+    private static final String UNKNOWN_OWNERSHIP = "UNKNOWN";
+
+    private final CockpitInfrastructureManagementInformationAdapter adapter;
+    private final Clock clock;
+
+    public CockpitInfrastructureManagementInformationService(
+            CockpitInfrastructureManagementInformationAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitInfrastructureManagementInformationResponse load(
+            String requestId,
+            String industry,
+            String organizationCode) {
+        String selectedIndustry = resolveIndustry(industry);
+        String selectedOrganization = isBlank(organizationCode) ? null : organizationCode.trim();
+
+        CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
+        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
+                snapshot == null || snapshot.getRelations() == null
+                        ? Collections.emptyList()
+                        : snapshot.getRelations();
+
+        Set<String> seenObjects = new LinkedHashSet<>();
+        int duplicateRelationCount = 0;
+        int unknownIndustryCount = 0;
+        int nonFormalIndustryCount = 0;
+        int unknownObjectKindCount = 0;
+        Map<String, OrganizationAccumulator> accumulators = new LinkedHashMap<>();
+
+        for (CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation : relations) {
+            if (relation == null || isBlank(relation.getObjectCode())) {
+                continue;
+            }
+            String kind = normalize(relation.getObjectKind());
+            String objectKey = kind + "|" + relation.getObjectCode().trim();
+            if (!seenObjects.add(objectKey)) {
+                duplicateRelationCount++;
+                continue;
+            }
+            if (!FACILITY.equals(kind) && !SMART_DEVICE.equals(kind)) {
+                unknownObjectKindCount++;
+                continue;
+            }
+            if (isBlank(relation.getIndustry())) {
+                unknownIndustryCount++;
+                continue;
+            }
+            String industryCode = relation.getIndustry().trim();
+            if (!INDUSTRIES.contains(industryCode)) {
+                nonFormalIndustryCount++;
+                continue;
+            }
+            if (selectedIndustry != null && !selectedIndustry.equals(industryCode)) {
+                continue;
+            }
+            boolean unmaintained = isBlank(relation.getOrganizationCode());
+            String code = unmaintained ? UNMAINTAINED_CODE : relation.getOrganizationCode().trim();
+            if (selectedOrganization != null && !selectedOrganization.equalsIgnoreCase(code)) {
+                continue;
+            }
+            OrganizationAccumulator accumulator = accumulators.computeIfAbsent(
+                    code, key -> new OrganizationAccumulator(key, unmaintained));
+            accumulator.add(industryCode, kind, ownershipType(relation), relation);
+        }
+
+        List<CockpitInfrastructureManagementInformationResponse.Organization> organizations = new ArrayList<>();
+        for (OrganizationAccumulator accumulator : accumulators.values()) {
+            organizations.add(accumulator.build());
+        }
+        organizations.sort(Comparator
+                .comparingInt(CockpitInfrastructureManagementInformationResponse.Organization::getFacilityCount)
+                .thenComparingInt(CockpitInfrastructureManagementInformationResponse.Organization::getEquipmentCount)
+                .reversed()
+                .thenComparing(CockpitInfrastructureManagementInformationResponse.Organization::getOrganizationCode));
+
+        Map<String, OwnershipCounter> overallOwnership = new LinkedHashMap<>();
+        List<String> organizationCodes = new ArrayList<>();
+        int facilityCount = 0;
+        int equipmentCount = 0;
+        int unmaintainedCount = 0;
+        for (CockpitInfrastructureManagementInformationResponse.Organization organization : organizations) {
+            organizationCodes.add(organization.getOrganizationCode());
+            facilityCount += organization.getFacilityCount();
+            equipmentCount += organization.getEquipmentCount();
+            if (organization.isUnmaintained()) {
+                unmaintainedCount += organization.getFacilityCount() + organization.getEquipmentCount();
+            }
+            for (CockpitInfrastructureManagementInformationResponse.Ownership ownership
+                    : organization.getOwnershipDistribution()) {
+                overallOwnership
+                        .computeIfAbsent(ownership.getOwnershipType(), key -> new OwnershipCounter())
+                        .add(ownership.getFacilityCount(), ownership.getEquipmentCount());
+            }
+        }
+
+        List<CockpitInfrastructureManagementInformationResponse.Ownership> ownershipDistribution =
+                new ArrayList<>();
+        for (Map.Entry<String, OwnershipCounter> entry : overallOwnership.entrySet()) {
+            OwnershipCounter counter = entry.getValue();
+            ownershipDistribution.add(new CockpitInfrastructureManagementInformationResponse.Ownership(
+                    entry.getKey(), counter.facilityCount, counter.equipmentCount, counter.total()));
+        }
+        ownershipDistribution.sort(Comparator
+                .comparingInt(CockpitInfrastructureManagementInformationResponse.Ownership::getTotal)
+                .reversed()
+                .thenComparing(CockpitInfrastructureManagementInformationResponse.Ownership::getOwnershipType));
+
+        String serverUpdatedAt = businessTime();
+        String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
+                ? serverUpdatedAt
+                : snapshot.getUpdatedAt();
+
+        return new CockpitInfrastructureManagementInformationResponse(
+                requestId,
+                "v1",
+                true,
+                "管理信息",
+                "Asia/Shanghai",
+                updatedAt,
+                new CockpitInfrastructureManagementInformationResponse.Freshness(
+                        organizations.isEmpty()
+                                ? CockpitFreshnessStatus.EMPTY.name()
+                                : CockpitFreshnessStatus.CURRENT.name(),
+                        organizations.isEmpty() ? "NO_ORGANIZATIONS" : "READY",
+                        updatedAt,
+                        serverUpdatedAt),
+                new CockpitInfrastructureManagementInformationResponse.SelectedFilters(
+                        selectedIndustry, selectedOrganization),
+                new CockpitInfrastructureManagementInformationResponse.Totals(
+                        organizations.size(), facilityCount, equipmentCount, unmaintainedCount),
+                organizations,
+                ownershipDistribution,
+                new CockpitInfrastructureManagementInformationResponse.MapFilter(
+                        selectedIndustry, selectedOrganization, organizationCodes),
+                new CockpitInfrastructureManagementInformationResponse.Excluded(
+                        unknownIndustryCount,
+                        nonFormalIndustryCount,
+                        duplicateRelationCount,
+                        unknownObjectKindCount));
+    }
+
+    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 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 String ownershipType(
+            CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation) {
+        if (isBlank(relation.getOwnershipType())) {
+            return UNKNOWN_OWNERSHIP;
+        }
+        return relation.getOwnershipType().trim().toUpperCase();
+    }
+
+    private static String normalize(String value) {
+        return isBlank(value) ? "" : value.trim().toUpperCase();
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
+                OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static class OwnershipCounter {
+        private int facilityCount;
+        private int equipmentCount;
+
+        private void add(int facilities, int equipment) {
+            this.facilityCount += facilities;
+            this.equipmentCount += equipment;
+        }
+
+        private int total() {
+            return facilityCount + equipmentCount;
+        }
+    }
+
+    private static class OrganizationAccumulator {
+        private final String code;
+        private final boolean unmaintained;
+        private String name;
+        private final Set<String> industries = new LinkedHashSet<>();
+        private int facilityCount;
+        private int equipmentCount;
+        private final Map<String, OwnershipCounter> ownership = new LinkedHashMap<>();
+
+        private OrganizationAccumulator(String code, boolean unmaintained) {
+            this.code = code;
+            this.unmaintained = unmaintained;
+            this.name = unmaintained ? UNMAINTAINED_NAME : code;
+        }
+
+        private void add(
+                String industry,
+                String kind,
+                String ownershipType,
+                CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation) {
+            if (!unmaintained && !isBlank(relation.getOrganizationName())) {
+                this.name = relation.getOrganizationName().trim();
+            }
+            industries.add(industry);
+            if (FACILITY.equals(kind)) {
+                facilityCount++;
+            } else {
+                equipmentCount++;
+            }
+            ownership.computeIfAbsent(ownershipType, key -> new OwnershipCounter()).add(
+                    FACILITY.equals(kind) ? 1 : 0,
+                    SMART_DEVICE.equals(kind) ? 1 : 0);
+        }
+
+        private CockpitInfrastructureManagementInformationResponse.Organization build() {
+            List<CockpitInfrastructureManagementInformationResponse.Ownership> distribution = new ArrayList<>();
+            for (Map.Entry<String, OwnershipCounter> entry : ownership.entrySet()) {
+                OwnershipCounter counter = entry.getValue();
+                distribution.add(new CockpitInfrastructureManagementInformationResponse.Ownership(
+                        entry.getKey(), counter.facilityCount, counter.equipmentCount, counter.total()));
+            }
+            distribution.sort(Comparator
+                    .comparingInt(CockpitInfrastructureManagementInformationResponse.Ownership::getTotal)
+                    .reversed()
+                    .thenComparing(CockpitInfrastructureManagementInformationResponse.Ownership::getOwnershipType));
+            String industry = industries.isEmpty()
+                    ? null
+                    : industries.size() == 1 ? industries.iterator().next() : "MIXED";
+            return new CockpitInfrastructureManagementInformationResponse.Organization(
+                    code, name, industry, facilityCount, equipmentCount, unmaintained, distribution);
+        }
+    }
+}

+ 52 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationSnapshot.java

@@ -0,0 +1,52 @@
+package com.zksy.cockpit.infrastructure;
+
+import java.util.List;
+
+public class CockpitInfrastructureManagementInformationSnapshot {
+    private final String updatedAt;
+    private final List<SourceRelation> relations;
+
+    public CockpitInfrastructureManagementInformationSnapshot(
+            String updatedAt,
+            List<SourceRelation> relations) {
+        this.updatedAt = updatedAt;
+        this.relations = relations;
+    }
+
+    public String getUpdatedAt() { return updatedAt; }
+    public List<SourceRelation> getRelations() { return relations; }
+
+    /**
+     * One management-ownership relation: an object, the unit that manages it and the ownership type.
+     */
+    public static class SourceRelation {
+        private final String objectCode;
+        private final String objectKind;
+        private final String industry;
+        private final String organizationCode;
+        private final String organizationName;
+        private final String ownershipType;
+
+        public SourceRelation(
+                String objectCode,
+                String objectKind,
+                String industry,
+                String organizationCode,
+                String organizationName,
+                String ownershipType) {
+            this.objectCode = objectCode;
+            this.objectKind = objectKind;
+            this.industry = industry;
+            this.organizationCode = organizationCode;
+            this.organizationName = organizationName;
+            this.ownershipType = ownershipType;
+        }
+
+        public String getObjectCode() { return objectCode; }
+        public String getObjectKind() { return objectKind; }
+        public String getIndustry() { return industry; }
+        public String getOrganizationCode() { return organizationCode; }
+        public String getOrganizationName() { return organizationName; }
+        public String getOwnershipType() { return ownershipType; }
+    }
+}

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/EmptyCockpitInfrastructureManagementInformationAdapter.java

@@ -0,0 +1,12 @@
+package com.zksy.cockpit.infrastructure;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmptyCockpitInfrastructureManagementInformationAdapter
+        implements CockpitInfrastructureManagementInformationAdapter {
+    @Override
+    public CockpitInfrastructureManagementInformationSnapshot load() {
+        return null;
+    }
+}