Procházet zdrojové kódy

fix(cockpit): 管网编码 0/1 映射与设备边界设施类型命名空间隔离

执行票 20(发现于票 18 真实数据口径核对,真人走查第 4 项佐证:
界面设施总数 1001、实际 5268)。

## 数据事实(真实库实测)

pipe_network_base.network_type 取值 `0`=537、`1`=3735、`2`=996,合计 5268;
业务 GIS 既有权威规则为 `0`=燃气、`1`=排水、`2`=供水(GisController)。
`equipment_type` 主键命名空间为 id=1「供水设备」、id=2「排水设备」。

## 三个现象的根因与修复

1. **4272 条管网整体丢失**:三个适配器各自复制了一份映射逻辑(pipeType /
   pipeIndustry ×2),都只认 `2`/`3` 与中文串,遇到 `0`/`1` 返回 null 后被
   调用方 `continue` 静默丢弃。现统一到单一权威来源,0/1/2 全部解析。

2. **命名空间碰撞导致类型名与行业系统性错位**:设备边界把原始 network_type
   透传给 equipment_type 字典查询,两套编码数值域重叠而语义不同 —— 真实库里
   3735 条排水设施被标成「供水设备 / WATER_SUPPLY」、996 条供水设施被标成
   「排水设备 / DRAINAGE」。现设施改用**管网命名空间**类型码
   (GAS_PIPE/WATER_PIPE/DRAINAGE_PIPE,objectKind=FACILITY),与设备类型
   命名空间彻底隔离。原类注释「network_type 是自由文本、与 equipment_type.id
   无对应关系」的前提为假,已改正。

3. **概览把 5268 条管网整体排除**:设施 SourceType 此前 industry 恒传 null,
   服务层据实计入 excluded.unknownTypeCount。现由权威映射给出行业。

## 不静默丢弃

无法映射的取值归入非空的未分类哨兵码 PIPE_UNCLASSIFIED,并由各端点既有的
excluded 计数如实承接(base-archives/overview/device-boundary 的
unknownTypeCount,management/operating 的 unknownIndustryCount)——
四个服务的承接逻辑本就存在,是适配器提前 continue 让它们恒为 0。契约无变更。

维护者裁定:删除旧映射 "3"→燃气,严格执行 GIS 权威规则(真实库无 "3",
且它与 equipment_type.id=3 同源混淆)。

## 验证

全量 8 模块 439/439 通过(新增 17 例)。变异检验确认测试有牙齿:还原命名空间
隔离后精确复现票面症状(排水设施→供水设备、供水设施→排水设备、[0,1,2]
原始透传)7 例转红;还原概览行业后 1 例转红。
Kazerin před 1 týdnem
rodič
revize
740d896808
13 změnil soubory, kde provedl 685 přidání a 113 odebrání
  1. 120 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceBoundaryAdapterTest.java
  2. 4 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitFoundationAdaptersTest.java
  3. 36 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureBaseArchivesAdapterTest.java
  4. 25 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureOperatingInformationAdapterTest.java
  5. 45 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitOverviewInfrastructureAdapterTest.java
  6. 127 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryPipeIndustryTest.java
  7. 99 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPipeNetworkTypeTest.java
  8. 31 14
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/BusinessCockpitDeviceBoundaryAdapter.java
  9. 171 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitPipeNetworkType.java
  10. 6 28
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java
  11. 5 28
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureManagementInformationAdapter.java
  12. 6 31
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureOperatingInformationAdapter.java
  13. 10 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewInfrastructureAdapter.java

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

@@ -0,0 +1,120 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.device.BusinessCockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 设备边界的**命名空间隔离**契约(执行票 20 现象 2)。
+ *
+ * <p>回归背景:适配器曾把原始 {@code network_type}({@code 0/1/2})透传给
+ * {@code equipment_type} 字典查询。两套编码数值域重叠而语义不同,于是真实库里
+ * 3735 条<b>排水</b>设施被标成「供水设备 / WATER_SUPPLY」、996 条<b>供水</b>设施被标成
+ * 「排水设备 / DRAINAGE」。本测试固定住「设施用管网命名空间、不借用设备类型命名空间」。</p>
+ */
+class BusinessCockpitDeviceBoundaryAdapterTest {
+
+    private BusinessCockpitDeviceBoundaryAdapter adapter;
+    private EquipmentTypeService equipmentTypeService;
+    private EquipmentBaseService equipmentBaseService;
+    private PipeNetworkBaseService pipeNetworkBaseService;
+
+    @BeforeEach
+    void setUp() {
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        pipeNetworkBaseService = mock(PipeNetworkBaseService.class);
+        adapter = new BusinessCockpitDeviceBoundaryAdapter(
+                equipmentTypeService, equipmentBaseService, pipeNetworkBaseService);
+        // 设备类型命名空间:id=1 是「供水设备」,id=2 是「排水设备」——与 network_type 数值域重叠。
+        when(equipmentTypeService.list()).thenReturn(Arrays.asList(type(1, "供水设备", 0), type(2, "排水设备", 0)));
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+    }
+
+    @Test
+    void facilityTypeCodesNeverCollideWithEquipmentTypePrimaryKeys() {
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(
+                pipe("P-GAS", "燃气管线", "0"),
+                pipe("P-DRAIN", "排水管线", "1"),
+                pipe("P-WATER", "供水管线", "2")));
+
+        CockpitDeviceBoundarySnapshot snapshot = adapter.load();
+
+        assertEquals(Arrays.asList("GAS_PIPE", "DRAINAGE_PIPE", "WATER_PIPE"),
+                snapshot.getFacilities().stream()
+                        .map(CockpitDeviceBoundarySnapshot.SourceFacility::getTypeCode)
+                        .collect(java.util.stream.Collectors.toList()));
+        // 关键:设施类型码不得再是 "0"/"1"/"2"(那正是与 equipment_type.id 碰撞的根源)
+        for (CockpitDeviceBoundarySnapshot.SourceFacility facility : snapshot.getFacilities()) {
+            assertNotEquals("1", facility.getTypeCode());
+            assertNotEquals("2", facility.getTypeCode());
+        }
+    }
+
+    @Test
+    void pipeFacilityTypesAreRegisteredWithCorrectIndustryAndObjectKind() {
+        when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(pipe("P-DRAIN", "排水管线", "1")));
+
+        CockpitDeviceBoundarySnapshot snapshot = adapter.load();
+
+        CockpitDeviceBoundarySnapshot.SourceType drainage = snapshot.getTypes().stream()
+                .filter(type -> "DRAINAGE_PIPE".equals(type.getCode()))
+                .findFirst().orElseThrow(AssertionError::new);
+        assertEquals("排水管线", drainage.getName());
+        assertEquals("DRAINAGE", drainage.getIndustry());
+        assertEquals("FACILITY", drainage.getObjectKind());
+
+        // 设备类型条目仍是 SMART_DEVICE,两套命名空间并存且互不覆盖
+        CockpitDeviceBoundarySnapshot.SourceType waterDevice = snapshot.getTypes().stream()
+                .filter(type -> "1".equals(type.getCode()))
+                .findFirst().orElseThrow(AssertionError::new);
+        assertEquals("供水设备", waterDevice.getName());
+        assertEquals("SMART_DEVICE", waterDevice.getObjectKind());
+    }
+
+    @Test
+    void unclassifiablePipeKeepsSentinelCodeSoServiceCountsItAsUnknown() {
+        when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(
+                pipe("P-ODD", "来路不明管线", "99")));
+
+        CockpitDeviceBoundarySnapshot snapshot = adapter.load();
+
+        // 哨兵码不在字典中 → 服务层 unknownTypeCount 会如实计数,设施不静默消失
+        assertEquals("PIPE_UNCLASSIFIED",
+                snapshot.getFacilities().get(0).getTypeCode());
+        assertEquals(0, snapshot.getTypes().stream()
+                .filter(type -> "PIPE_UNCLASSIFIED".equals(type.getCode()))
+                .count(), "未分类哨兵不应进正式字典");
+    }
+
+    private static EquipmentType type(Integer id, String name, Integer parentId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(parentId);
+        return type;
+    }
+
+    private static PipeNetworkBase pipe(String code, String name, String networkType) {
+        PipeNetworkBase facility = new PipeNetworkBase();
+        facility.setNetworkCode(code);
+        facility.setNetworkName(name);
+        facility.setNetworkType(networkType);
+        return facility;
+    }
+}

+ 4 - 2
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitFoundationAdaptersTest.java

@@ -95,7 +95,8 @@ class BusinessCockpitFoundationAdaptersTest {
                 new BusinessCockpitDeviceBoundaryAdapter(
                         equipmentTypeService, equipmentBaseService, pipeNetworkBaseService).load();
 
-        assertEquals(2, snapshot.getTypes().size());
+        // 设备类型 2 条 + 管网设施类型 3 条(管网命名空间,执行票 20)
+        assertEquals(5, snapshot.getTypes().size());
         assertEquals("11", snapshot.getTypes().get(1).getCode());
         assertEquals("GAS", snapshot.getTypes().get(1).getIndustry());
         assertEquals("SMART_DEVICE", snapshot.getTypes().get(1).getObjectKind());
@@ -107,7 +108,8 @@ class BusinessCockpitFoundationAdaptersTest {
 
         assertEquals(1, snapshot.getFacilities().size());
         assertEquals("PIPE-001", snapshot.getFacilities().get(0).getFacilityCode());
-        assertEquals("主管", snapshot.getFacilities().get(0).getTypeCode());
+        // 自由文本「主管」不在权威编码内 → 未分类哨兵码,不再把原始值当类型码(执行票 20)
+        assertEquals("PIPE_UNCLASSIFIED", snapshot.getFacilities().get(0).getTypeCode());
     }
 
     @Test

+ 36 - 2
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureBaseArchivesAdapterTest.java

@@ -8,6 +8,7 @@ import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
 import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
 import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureBaseArchivesAdapter;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesSnapshot;
 import com.zksy.drainage.domain.EngineeringFacility;
@@ -22,8 +23,11 @@ import java.time.LocalDate;
 import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -115,7 +119,7 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
     }
 
     @Test
-    void mapsDrainageAndGasPipeKindsAndSkipsUnclassifiableRows() {
+    void mapsAuthoritativeNetworkTypeCodesAndKeepsUnclassifiableRows() {
         PipeNetworkBase drainage = pipe("DRAIN-PIPE", "排水管线", "drainage", "1000");
         PipeNetworkBase gas = pipe("GAS-PIPE", "燃气管线", "GAS", "500");
         PipeNetworkBase unknown = pipe("UNKNOWN-PIPE", "未知管线", "99", "500");
@@ -126,9 +130,39 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
 
         CockpitInfrastructureBaseArchivesSnapshot snapshot = adapter.load();
 
-        assertEquals(2, snapshot.getFacilities().size());
+        // 无法映射的取值**不再被静默丢弃**:留在快照里并归未分类哨兵码,
+        // 由服务层计入 excluded.unknownTypeCount(执行票 20)。
+        assertEquals(3, snapshot.getFacilities().size());
         assertEquals("DRAINAGE_PIPE", snapshot.getFacilities().get(0).getTypeCode());
         assertEquals("GAS_PIPE", snapshot.getFacilities().get(1).getTypeCode());
+        assertEquals("PIPE_UNCLASSIFIED", snapshot.getFacilities().get(2).getTypeCode());
+    }
+
+    /**
+     * 业务 GIS 权威编码 {@code 0/1/2}(燃气/排水/供水)必须全部进响应 ——
+     * 此前只认 {@code 2} 与 {@code 3},导致 4272 条管网整体丢失(执行票 20 现象 1)。
+     */
+    @Test
+    void mapsAllAuthoritativeGisNetworkTypeCodes() {
+        PipeNetworkBase gas = pipe("P-GAS", "燃气管线", "0", "500");
+        PipeNetworkBase drainage = pipe("P-DRAIN", "排水管线", "1", "1000");
+        PipeNetworkBase water = pipe("P-WATER", "供水管线", "2", "1500");
+        when(plantService.list()).thenReturn(Collections.emptyList());
+        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(gas, drainage, water));
+        when(engineeringFacilityService.list()).thenReturn(Collections.emptyList());
+
+        CockpitInfrastructureBaseArchivesSnapshot snapshot = adapter.load();
+
+        List<String> typeCodes = snapshot.getFacilities().stream()
+                .map(CockpitInfrastructureBaseArchivesSnapshot.SourceFacility::getTypeCode)
+                .collect(Collectors.toList());
+        assertEquals(Arrays.asList("GAS_PIPE", "DRAINAGE_PIPE", "WATER_PIPE"), typeCodes);
+        // 0/1/2 三类都在正式类型字典里,不应被计入 excluded.unknownTypeCount
+        assertTrue(CockpitPipeNetworkType.isFormal("GAS_PIPE"));
+        assertTrue(CockpitPipeNetworkType.isFormal("DRAINAGE_PIPE"));
+        assertTrue(CockpitPipeNetworkType.isFormal("WATER_PIPE"));
+        assertFalse(CockpitPipeNetworkType.isFormal("PIPE_UNCLASSIFIED"));
     }
 
     private static PipeNetworkBase pipe(String code, String name, String type, String length) {

+ 25 - 2
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureOperatingInformationAdapterTest.java

@@ -19,6 +19,7 @@ import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -116,7 +117,7 @@ class BusinessCockpitInfrastructureOperatingInformationAdapterTest {
     }
 
     @Test
-    void mapsDrainageAndGasPipesAndSkipsUnclassifiableRows() {
+    void mapsAuthoritativeNetworkTypeCodesAndKeepsUnclassifiableRows() {
         PipeNetworkBase drainage = pipe("DRAIN-PIPE", "排水管线", "drainage", "1000");
         PipeNetworkBase gas = pipe("GAS-PIPE", "燃气管线", "GAS", "500");
         PipeNetworkBase unknown = pipe("UNKNOWN-PIPE", "未知管线", "99", "500");
@@ -127,9 +128,31 @@ class BusinessCockpitInfrastructureOperatingInformationAdapterTest {
 
         CockpitInfrastructureOperatingInformationSnapshot snapshot = adapter.load();
 
-        assertEquals(2, snapshot.getMetrics().size());
+        // 无法映射的取值不再被静默丢弃:以空行业上行,由服务层计入
+        // excluded.unknownIndustryCount(执行票 20)。
+        assertEquals(3, snapshot.getMetrics().size());
         assertEquals("DRAINAGE", snapshot.getMetrics().get(0).getIndustry());
         assertEquals("GAS", snapshot.getMetrics().get(1).getIndustry());
+        assertNull(snapshot.getMetrics().get(2).getIndustry(), "未映射管网不臆造行业");
+        assertEquals("unknown_pipe_network_length", snapshot.getMetrics().get(2).getMetricCode());
+    }
+
+    /** 业务 GIS 权威编码 {@code 0/1/2} 必须全部映射出正确行业(执行票 20 现象 1)。 */
+    @Test
+    void mapsAllAuthoritativeGisNetworkTypeCodesToIndustries() {
+        when(plantService.list()).thenReturn(Collections.emptyList());
+        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(
+                pipe("P-GAS", "燃气管线", "0", "500"),
+                pipe("P-DRAIN", "排水管线", "1", "1000"),
+                pipe("P-WATER", "供水管线", "2", "1500")));
+        when(engineeringFacilityService.list()).thenReturn(Collections.emptyList());
+
+        CockpitInfrastructureOperatingInformationSnapshot snapshot = adapter.load();
+
+        assertEquals(Arrays.asList("GAS", "DRAINAGE", "WATER_SUPPLY"), snapshot.getMetrics().stream()
+                .map(CockpitInfrastructureOperatingInformationSnapshot.SourceMetric::getIndustry)
+                .collect(Collectors.toList()));
     }
 
     private static PipeNetworkBase pipe(String code, String name, String type, String length) {

+ 45 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitOverviewInfrastructureAdapterTest.java

@@ -23,6 +23,7 @@ import java.util.Arrays;
 import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
@@ -85,13 +86,56 @@ class BusinessCockpitOverviewInfrastructureAdapterTest {
         CockpitOverviewInfrastructureSnapshot.SourceCount facilityCount = snapshot.getCounts().stream()
                 .filter(count -> "FACILITY".equals(count.getObjectKind()))
                 .findFirst().orElseThrow(AssertionError::new);
-        assertEquals("主管", facilityCount.getTypeCode());
+        // 自由文本「主管」不在权威编码内 → 归未分类哨兵码(不再是原始值透传,执行票 20)
+        assertEquals("PIPE_UNCLASSIFIED", facilityCount.getTypeCode());
         assertEquals(1, facilityCount.getCount());
         assertEquals(0, new BigDecimal("5.000").compareTo(facilityCount.getValue().getLengthKm()));
         assertEquals("km", facilityCount.getValue().getScale());
         assertNull(facilityCount.getValue().getRange());
     }
 
+    /**
+     * 管网设施类型必须带出**正式行业**,否则 5268 条管网会被服务层整体计入
+     * {@code excluded.unknownTypeCount}(执行票 20 现象 3)。
+     */
+    @Test
+    void resolvesPipeFacilityIndustriesFromAuthoritativeCodes() {
+        when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(plantService.list()).thenReturn(Collections.emptyList());
+        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        java.util.List<PipeNetworkBase> pipes = new java.util.ArrayList<>();
+        pipes.add(pipe("P-GAS", "燃气管线", "0", "500"));
+        pipes.add(pipe("P-DRAIN", "排水管线", "1", "1000"));
+        pipes.add(pipe("P-WATER", "供水管线", "2", "1500"));
+        when(pipeNetworkBaseService.list()).thenReturn(pipes);
+
+        CockpitOverviewInfrastructureSnapshot snapshot = adapter.load();
+
+        for (CockpitOverviewInfrastructureSnapshot.SourceType type : snapshot.getTypes()) {
+            if (!"FACILITY".equals(type.getObjectKind())) {
+                continue;
+            }
+            assertNotNull(type.getIndustry(),
+                    "正式管网类型必须带行业,否则会被计入 excluded.unknownTypeCount");
+        }
+        assertEquals(3, snapshot.getCounts().stream()
+                .filter(count -> "FACILITY".equals(count.getObjectKind()))
+                .mapToInt(CockpitOverviewInfrastructureSnapshot.SourceCount::getCount)
+                .sum(), "0/1/2 三类管网都必须计入");
+    }
+
+    private static PipeNetworkBase pipe(String code, String name, String type, String length) {
+        PipeNetworkBase facility = new PipeNetworkBase();
+        facility.setNetworkId("N-" + code);
+        facility.setNetworkCode(code);
+        facility.setNetworkName(name);
+        facility.setNetworkType(type);
+        facility.setLength(new BigDecimal(length));
+        return facility;
+    }
+
     @Test
     void countsWaterPlantsAndSharedPumpStationsSeparately() {
         WaterPlantInfo plant = new WaterPlantInfo();

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

@@ -0,0 +1,127 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.device.BusinessCockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundaryResponse;
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.device.CockpitFacilityBoundary;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 设备边界「适配器 → 服务 → 响应」贯通契约(执行票 20 现象 2 的验收防线)。
+ *
+ * <p>回归背景:真实库里 {@code network_type=1} 是<b>排水</b>设施(3735 条),
+ * 旧实现把它透传去查 {@code equipment_type} 字典,命中 id=1「供水设备」,于是响应里
+ * 排水设施的类型名与行业都是「供水 / WATER_SUPPLY」。控制器测试用 mock 适配器,测不到
+ * 这一段;本测试用真实适配器 + 真实服务,只 mock 数据源。</p>
+ */
+class CockpitDeviceBoundaryPipeIndustryTest {
+
+    private CockpitDeviceBoundaryService service;
+
+    @BeforeEach
+    void setUp() {
+        EquipmentTypeService equipmentTypeService = mock(EquipmentTypeService.class);
+        EquipmentBaseService equipmentBaseService = mock(EquipmentBaseService.class);
+        PipeNetworkBaseService pipeNetworkBaseService = mock(PipeNetworkBaseService.class);
+        // 设备类型命名空间:id=1「供水设备」、id=2「排水设备」——与 network_type 数值域重叠。
+        when(equipmentTypeService.list()).thenReturn(Arrays.asList(
+                type(1, "供水设备", 0), type(2, "排水设备", 0)));
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(
+                pipe("P-GAS", "燃气管线", "0"),
+                pipe("P-DRAIN", "排水管线", "1"),
+                pipe("P-WATER", "供水管线", "2")));
+
+        service = new CockpitDeviceBoundaryService(
+                new BusinessCockpitDeviceBoundaryAdapter(
+                        equipmentTypeService, equipmentBaseService, pipeNetworkBaseService),
+                Clock.fixed(Instant.parse("2026-10-10T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void drainagePipeIsReportedAsDrainageNotWaterSupply() {
+        CockpitDeviceBoundaryResponse response = service.load("req-1");
+
+        CockpitFacilityBoundary drainage = facility(response, "P-DRAIN");
+        assertEquals("排水管线", drainage.getFacilityType().getName(),
+                "排水设施不得被标成「供水设备」");
+        assertEquals("DRAINAGE_PIPE", drainage.getFacilityType().getCode());
+        assertEquals("DRAINAGE", drainage.getIndustry());
+        assertTrue(drainage.isFormalScope());
+    }
+
+    @Test
+    void waterPipeIsReportedAsWaterSupplyNotDrainage() {
+        CockpitDeviceBoundaryResponse response = service.load("req-2");
+
+        CockpitFacilityBoundary water = facility(response, "P-WATER");
+        assertEquals("供水管线", water.getFacilityType().getName(),
+                "供水设施不得被标成「排水设备」");
+        assertEquals("WATER_SUPPLY", water.getIndustry());
+        assertTrue(water.isFormalScope());
+    }
+
+    @Test
+    void gasPipeIsReportedAsGasAndNoLongerCountedAsUnknownType() {
+        CockpitDeviceBoundaryResponse response = service.load("req-3");
+
+        CockpitFacilityBoundary gas = facility(response, "P-GAS");
+        assertEquals("燃气管线", gas.getFacilityType().getName());
+        assertEquals("GAS", gas.getIndustry());
+
+        // 旧实现下 network_type=0 查不到 equipment_type.id=0,会被计入 unknownTypeCount
+        assertEquals(0, response.getStatistics().getUnknownTypeCount(),
+                "0/1/2 三类管网都应解析成功,不该有未知类型");
+        assertEquals(3, response.getStatistics().getFacilityCount());
+    }
+
+    @Test
+    void pipeFacilitiesFlowIntoFormalIndustryStatistics() {
+        CockpitDeviceBoundaryResponse response = service.load("req-4");
+
+        assertEquals(1, response.getStatistics().getIndustries().get("gas").getFacilityCount());
+        assertEquals(1, response.getStatistics().getIndustries().get("drainage").getFacilityCount());
+        assertEquals(1, response.getStatistics().getIndustries().get("waterSupply").getFacilityCount());
+    }
+
+    private static CockpitFacilityBoundary facility(
+            CockpitDeviceBoundaryResponse response, String code) {
+        return response.getFacilities().stream()
+                .filter(item -> code.equals(item.getFacilityCode()))
+                .findFirst().orElseThrow(AssertionError::new);
+    }
+
+    private static EquipmentType type(Integer id, String name, Integer parentId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(parentId);
+        return type;
+    }
+
+    private static PipeNetworkBase pipe(String code, String name, String networkType) {
+        PipeNetworkBase facility = new PipeNetworkBase();
+        facility.setNetworkCode(code);
+        facility.setNetworkName(name);
+        facility.setNetworkType(networkType);
+        return facility;
+    }
+}

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

@@ -0,0 +1,99 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 管网设施类型单一权威来源契约(执行票 20)。
+ *
+ * <p>这组断言是全案的地基:真实库 {@code network_type} 取值为 {@code 0/1/2}
+ * (实测 537/3735/996,合计 5268),权威规则来自业务 GIS 既有实现
+ * {@code 0=燃气、1=排水、2=供水}。此前四个适配器各自复制映射逻辑且都漏了
+ * {@code 0/1},导致 4272 条管网整体丢失。</p>
+ */
+class CockpitPipeNetworkTypeTest {
+
+    @Test
+    void mapsAuthoritativeGisNumericCodes() {
+        assertEquals("GAS", CockpitPipeNetworkType.industryOf("0"));
+        assertEquals("DRAINAGE", CockpitPipeNetworkType.industryOf("1"));
+        assertEquals("WATER_SUPPLY", CockpitPipeNetworkType.industryOf("2"));
+    }
+
+    @Test
+    void mapsAuthoritativeGisNumericCodesToPipeNamespaceTypeCodes() {
+        assertEquals("GAS_PIPE", CockpitPipeNetworkType.typeCodeOf("0"));
+        assertEquals("DRAINAGE_PIPE", CockpitPipeNetworkType.typeCodeOf("1"));
+        assertEquals("WATER_PIPE", CockpitPipeNetworkType.typeCodeOf("2"));
+    }
+
+    @Test
+    void mapsHistoricalStringValuesAsGisControllerDoes() {
+        assertEquals("GAS", CockpitPipeNetworkType.industryOf("gas"));
+        assertEquals("GAS", CockpitPipeNetworkType.industryOf("燃气"));
+        assertEquals("GAS", CockpitPipeNetworkType.industryOf("燃气管网"));
+        assertEquals("DRAINAGE", CockpitPipeNetworkType.industryOf("drainage"));
+        assertEquals("DRAINAGE", CockpitPipeNetworkType.industryOf("SEWER"));
+        assertEquals("DRAINAGE", CockpitPipeNetworkType.industryOf("排水管网"));
+        assertEquals("WATER_SUPPLY", CockpitPipeNetworkType.industryOf("water"));
+        assertEquals("WATER_SUPPLY", CockpitPipeNetworkType.industryOf("WATER_SUPPLY"));
+        assertEquals("WATER_SUPPLY", CockpitPipeNetworkType.industryOf("供水管网"));
+    }
+
+    /**
+     * 数字码的语义与 {@code equipment_type.id} 数值域重叠但含义不同 ——
+     * 这是设备边界曾把排水设施标成「供水设备」的根因,单源必须保持两套命名空间互不干扰。
+     */
+    @Test
+    void pipeNamespaceIsIndependentOfEquipmentTypePrimaryKeys() {
+        // equipment_type.id=1 是「供水设备」,但 network_type=1 是「排水」
+        assertEquals("DRAINAGE_PIPE", CockpitPipeNetworkType.typeCodeOf("1"));
+        assertFalse(CockpitPipeNetworkType.isFormal("1"),
+                "设备类型主键不得被当成管网类型码使用");
+        assertFalse(CockpitPipeNetworkType.isFormal("2"));
+        assertFalse(CockpitPipeNetworkType.isFormal("0"));
+    }
+
+    /** 未映射取值归未分类哨兵:非空、可命名、无行业,且不计为正式类型。 */
+    @Test
+    void unmappedValuesFallBackToUnclassifiedSentinel() {
+        assertEquals("PIPE_UNCLASSIFIED", CockpitPipeNetworkType.typeCodeOf("99"));
+        assertEquals("PIPE_UNCLASSIFIED", CockpitPipeNetworkType.typeCodeOf("未知"));
+        assertEquals("PIPE_UNCLASSIFIED", CockpitPipeNetworkType.typeCodeOf(null));
+        assertEquals("PIPE_UNCLASSIFIED", CockpitPipeNetworkType.typeCodeOf("  "));
+        assertNull(CockpitPipeNetworkType.industryOf("99"));
+        assertNull(CockpitPipeNetworkType.industryOf(null));
+        assertEquals("未分类管网", CockpitPipeNetworkType.nameOf("PIPE_UNCLASSIFIED"));
+        assertNull(CockpitPipeNetworkType.industryOfTypeCode("PIPE_UNCLASSIFIED"));
+        assertFalse(CockpitPipeNetworkType.isFormal("PIPE_UNCLASSIFIED"));
+    }
+
+    /**
+     * 维护者裁定(执行票 20):删除旧映射 {@code "3"→燃气},严格执行 GIS 权威规则。
+     * 真实库无 {@code 3};若外部真写入,该值属未识别而非燃气。
+     */
+    @Test
+    void legacyCodeThreeIsNotTreatedAsGas() {
+        assertNull(CockpitPipeNetworkType.industryOf("3"));
+        assertEquals("PIPE_UNCLASSIFIED", CockpitPipeNetworkType.typeCodeOf("3"));
+    }
+
+    @Test
+    void typeDictionaryNamesAndIndustriesAreConsistent() {
+        assertEquals(3, CockpitPipeNetworkType.types().size());
+        assertEquals("燃气管线", CockpitPipeNetworkType.nameOf("GAS_PIPE"));
+        assertEquals("供水管线", CockpitPipeNetworkType.nameOf("WATER_PIPE"));
+        assertEquals("排水管线", CockpitPipeNetworkType.nameOf("DRAINAGE_PIPE"));
+        assertEquals("GAS", CockpitPipeNetworkType.industryOfTypeCode("GAS_PIPE"));
+        assertEquals("WATER_SUPPLY", CockpitPipeNetworkType.industryOfTypeCode("WATER_PIPE"));
+        assertEquals("DRAINAGE", CockpitPipeNetworkType.industryOfTypeCode("DRAINAGE_PIPE"));
+        assertNull(CockpitPipeNetworkType.nameOf("NOT_A_TYPE"));
+        assertNull(CockpitPipeNetworkType.industryOfTypeCode("NOT_A_TYPE"));
+        assertTrue(CockpitPipeNetworkType.isFormal("GAS_PIPE"));
+    }
+}

+ 31 - 14
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/BusinessCockpitDeviceBoundaryAdapter.java

@@ -6,6 +6,7 @@ import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
@@ -21,13 +22,24 @@ import java.util.stream.Collectors;
 /**
  * 设备边界真实数据适配器(BT-5)。
  *
- * <p>类型字典来自 {@code equipment_type}:对外 {@code code = String(id)},行业沿类型树上溯推导,
- * 类型对象种类统一为 {@code SMART_DEVICE}(设备类型);{@code equipment_type} 无排水口径列,
- * 因此 {@code drainageScope} 如实为 {@code null}(服务层按非雨水/非污水统计)。
- * 设备来自 {@code equipment_base},设施来自 {@code pipe_network_base}。
+ * <p>类型字典有两套**互不相干**的命名空间(执行票 20):
+ * <ul>
+ *   <li><b>设备</b>:{@code equipment_type},对外 {@code code = String(id)},行业沿类型树
+ *       上溯推导,{@code objectKind=SMART_DEVICE};{@code equipment_type} 无排水口径列,
+ *       因此 {@code drainageScope} 如实为 {@code null}(服务层按非雨水/非污水统计)。</li>
+ *   <li><b>管网设施</b>:{@code pipe_network_base.network_type},权威映射见
+ *       {@link CockpitPipeNetworkType}({@code 0}-燃气 / {@code 1}-排水 / {@code 2}-供水),
+ *       对外使用<strong>管网命名空间</strong>类型码({@code GAS_PIPE/WATER_PIPE/DRAINAGE_PIPE}),
+ *       {@code objectKind=FACILITY}。</li>
+ * </ul>
  *
- * <p><b>不臆造</b>:管网设施 {@code network_type} 是自由文本、与 {@code equipment_type.id} 无对应关系,
- * 设施按自身类型编码上报,行业解析失败即为非正式范围(保留在清单,不计入正式行业统计)。
+ * <p><b>为什么必须隔离</b>:两套编码的数值域重叠而语义不同({@code equipment_type.id=1}
+ * 是「供水设备」,{@code network_type=1} 是「排水」)。此前把原始 {@code network_type}
+ * 透传给设备类型字典查询,导致 3735 条排水设施被标成「供水设备 / WATER_SUPPLY」、
+ * 996 条供水设施被标成「排水设备 / DRAINAGE」——这是命名空间碰撞,不是缺数据。</p>
+ *
+ * <p><b>不臆造、不丢弃</b>:无法映射的 {@code network_type} 归未分类哨兵码,
+ * 该码不在字典中,由服务层计入 {@code unknownTypeCount}(保留在清单,不计入正式行业统计)。
  */
 @Component
 @Primary
@@ -63,6 +75,16 @@ public class BusinessCockpitDeviceBoundaryAdapter implements CockpitDeviceBounda
                     "SMART_DEVICE"));
         }
 
+        // 管网设施命名空间的类型条目:与设备类型字典并列注入,objectKind=FACILITY 以示区分。
+        for (CockpitPipeNetworkType.Type pipeType : CockpitPipeNetworkType.types()) {
+            sourceTypes.add(new CockpitDeviceBoundarySnapshot.SourceType(
+                    pipeType.getCode(),
+                    pipeType.getName(),
+                    pipeType.getIndustry(),
+                    null,
+                    "FACILITY"));
+        }
+
         List<CockpitDeviceBoundarySnapshot.SourceDevice> sourceDevices = new ArrayList<>();
         for (EquipmentBase device : safeList(equipmentBaseService.list())) {
             if (device == null || isBlank(device.getEquipmentCode())) {
@@ -79,10 +101,12 @@ public class BusinessCockpitDeviceBoundaryAdapter implements CockpitDeviceBounda
             if (facility == null || isBlank(facility.getNetworkCode())) {
                 continue;
             }
+            // 解析到管网命名空间类型码,而不是把原始 network_type(0/1/2)透传给
+            // equipment_type 字典 —— 后者会造成命名空间碰撞(执行票 20)。
             sourceFacilities.add(new CockpitDeviceBoundarySnapshot.SourceFacility(
                     facility.getNetworkCode().trim(),
                     facility.getNetworkName(),
-                    trimToNull(facility.getNetworkType())));
+                    CockpitPipeNetworkType.typeCodeOf(facility.getNetworkType())));
         }
 
         return new CockpitDeviceBoundarySnapshot(sourceTypes, sourceFacilities, sourceDevices);
@@ -98,13 +122,6 @@ public class BusinessCockpitDeviceBoundaryAdapter implements CockpitDeviceBounda
         return result;
     }
 
-    private static String trimToNull(String value) {
-        if (value == null || value.trim().isEmpty()) {
-            return null;
-        }
-        return value.trim();
-    }
-
     private static <T> List<T> safeList(List<T> value) {
         return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
     }

+ 171 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitPipeNetworkType.java

@@ -0,0 +1,171 @@
+package com.zksy.cockpit.foundation;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 管网设施类型与行业的**单一权威来源**(执行票 20)。
+ *
+ * <p>编码规则以业务 GIS 既有实现为准({@code GisController.getPipeLines}):
+ * {@code pipe_network_base.network_type} 的 {@code 0=燃气 / 1=排水 / 2=供水},
+ * 并兼容历史页面传入的 {@code gas/drainage/water} 与中文串。</p>
+ *
+ * <p><b>命名空间</b>:本类对外的类型码({@code GAS_PIPE/WATER_PIPE/DRAINAGE_PIPE})是
+ * <b>管网设施自己的命名空间</b>,与监测设备类型 {@code equipment_type.id}({@code 1/11/12…})
+ * <b>没有任何对应关系</b>。此前设备边界把原始 {@code network_type} 透传给
+ * {@code equipment_type} 字典查询,导致数值域重叠而语义错位(排水设施被标成
+ * 「供水设备」)。设施<b>不得</b>借用设备类型命名空间。</p>
+ *
+ * <p><b>不静默丢弃</b>:无法映射的 {@code network_type} 一律归入
+ * {@link #UNCLASSIFIED_CODE}(非空、且不在任何正式字典中),由各端点的
+ * {@code excluded.unknownTypeCount} / {@code unknownIndustryCount} 如实计数,
+ * 而不是被适配器 {@code continue} 掉。</p>
+ */
+public final class CockpitPipeNetworkType {
+
+    /** 管网命名空间下的正式类型码。 */
+    public static final String GAS_PIPE = "GAS_PIPE";
+    public static final String WATER_PIPE = "WATER_PIPE";
+    public static final String DRAINAGE_PIPE = "DRAINAGE_PIPE";
+
+    /** 无法映射的管网类型哨兵码:显式存在、但不属任何正式行业。 */
+    public static final String UNCLASSIFIED_CODE = "PIPE_UNCLASSIFIED";
+    public static final String UNCLASSIFIED_NAME = "未分类管网";
+
+    /** 权威类型定义(码 → 展示名 → 行业)。 */
+    private static final List<Type> TYPES = Collections.unmodifiableList(Arrays.asList(
+            new Type(GAS_PIPE, "燃气管线", "GAS"),
+            new Type(WATER_PIPE, "供水管线", "WATER_SUPPLY"),
+            new Type(DRAINAGE_PIPE, "排水管线", "DRAINAGE")));
+
+    private static final Map<String, Type> BY_CODE = new LinkedHashMap<>();
+
+    static {
+        for (Type type : TYPES) {
+            BY_CODE.put(type.code, type);
+        }
+    }
+
+    private CockpitPipeNetworkType() {
+    }
+
+    /**
+     * {@code network_type} → 管网类型码({@code GAS_PIPE/WATER_PIPE/DRAINAGE_PIPE})。
+     *
+     * <p>无法映射时返回 {@link #UNCLASSIFIED_CODE} 而非 {@code null}:调用方据此把设施
+     * 留在响应里并计入 excluded,不静默丢弃。</p>
+     */
+    public static String typeCodeOf(String networkType) {
+        String industry = industryOf(networkType);
+        if (industry == null) {
+            return UNCLASSIFIED_CODE;
+        }
+        switch (industry) {
+            case "GAS":
+                return GAS_PIPE;
+            case "WATER_SUPPLY":
+                return WATER_PIPE;
+            default:
+                return DRAINAGE_PIPE;
+        }
+    }
+
+    /**
+     * {@code network_type} → 行业({@code GAS/WATER_SUPPLY/DRAINAGE}),无法映射回 {@code null}。
+     *
+     * <p>数字码按 GIS 权威规则:{@code 0}=燃气、{@code 1}=排水、{@code 2}=供水。</p>
+     */
+    public static String industryOf(String networkType) {
+        String normalized = trimToNull(networkType);
+        if (normalized == null) {
+            return null;
+        }
+        String lower = normalized.toLowerCase(Locale.ROOT);
+        // 数字编码:业务 GIS 既有规则(0-燃气、1-排水、2-供水)。
+        if ("0".equals(normalized)) {
+            return "GAS";
+        }
+        if ("1".equals(normalized)) {
+            return "DRAINAGE";
+        }
+        if ("2".equals(normalized)) {
+            return "WATER_SUPPLY";
+        }
+        // 历史页面串:与 GisController 的兼容集合保持一致。
+        if ("gas".equals(lower) || "燃气".equals(normalized) || "燃气管网".equals(normalized)) {
+            return "GAS";
+        }
+        if ("drainage".equals(lower) || "sewer".equals(lower)
+                || "排水".equals(normalized) || "排水管网".equals(normalized)) {
+            return "DRAINAGE";
+        }
+        if ("water".equals(lower) || "water_supply".equals(lower)
+                || "供水".equals(normalized) || "供水管网".equals(normalized)) {
+            return "WATER_SUPPLY";
+        }
+        return null;
+    }
+
+    /** 正式类型码列表(供各适配器建立类型字典,保证名称与行业一致)。 */
+    public static List<Type> types() {
+        return TYPES;
+    }
+
+    /** 展示名(含未分类哨兵),未知码回 {@code null}。 */
+    public static String nameOf(String typeCode) {
+        String normalized = trimToNull(typeCode);
+        if (normalized == null) {
+            return null;
+        }
+        if (UNCLASSIFIED_CODE.equals(normalized)) {
+            return UNCLASSIFIED_NAME;
+        }
+        Type type = BY_CODE.get(normalized);
+        return type == null ? null : type.name;
+    }
+
+    /** 行业(含未分类哨兵为 {@code null}),未知码回 {@code null}。 */
+    public static String industryOfTypeCode(String typeCode) {
+        String normalized = trimToNull(typeCode);
+        if (normalized == null || UNCLASSIFIED_CODE.equals(normalized)) {
+            return null;
+        }
+        Type type = BY_CODE.get(normalized);
+        return type == null ? null : type.industry;
+    }
+
+    /** 该类型码是否落在正式管网命名空间内(未分类哨兵与未知码均为 false)。 */
+    public static boolean isFormal(String typeCode) {
+        String normalized = trimToNull(typeCode);
+        return normalized != null && BY_CODE.containsKey(normalized);
+    }
+
+    private static String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+
+    /** 权威类型条目。 */
+    public static final class Type {
+        private final String code;
+        private final String name;
+        private final String industry;
+
+        public Type(String code, String name, String industry) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+    }
+}

+ 6 - 28
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java

@@ -8,6 +8,7 @@ import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
 import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
 import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.drainage.domain.EngineeringFacility;
 import com.zksy.drainage.service.EngineeringFacilityService;
 import org.springframework.context.annotation.Primary;
@@ -119,10 +120,13 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
     private void addPipes(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities) {
         for (PipeNetworkBase pipe : safeList(pipeNetworkBaseService.list())) {
             String code = trimToNull(pipe.getNetworkCode());
-            String typeCode = pipeType(pipe == null ? null : pipe.getNetworkType());
-            if (code == null || typeCode == null) {
+            if (code == null) {
                 continue;
             }
+            // 权威映射在 CockpitPipeNetworkType(0-燃气/1-排水/2-供水)。无法映射的取值
+            // 归入未分类哨兵码:设施仍留在响应里,并由服务层计入 excluded.unknownTypeCount,
+            // 不再被静默 continue 掉(执行票 20:4272 条管网曾因漏认 0/1 而整体丢失)。
+            String typeCode = CockpitPipeNetworkType.typeCodeOf(pipe.getNetworkType());
             BigDecimal lengthKm = pipe.getLength() == null
                     ? null : pipe.getLength().divide(BigDecimal.valueOf(1000));
             LocalDate builtAt = pipe.getBuildDate() == null
@@ -146,32 +150,6 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
         }
     }
 
-    private static String pipeType(String networkType) {
-        String normalized = trimToNull(networkType);
-        if (normalized == null) {
-            return null;
-        }
-        if ("2".equals(normalized)
-                || "WATER_SUPPLY".equalsIgnoreCase(normalized)
-                || "供水".equals(normalized)
-                || "供水管网".equals(normalized)) {
-            return "WATER_PIPE";
-        }
-        if ("GAS".equalsIgnoreCase(normalized)
-                || "3".equals(normalized)
-                || "燃气".equals(normalized)
-                || "燃气管网".equals(normalized)) {
-            return "GAS_PIPE";
-        }
-        if ("DRAINAGE".equalsIgnoreCase(normalized)
-                || "排水".equals(normalized)
-                || "排水管网".equals(normalized)
-                || "SEWER".equalsIgnoreCase(normalized)) {
-            return "DRAINAGE_PIPE";
-        }
-        return null;
-    }
-
     private static String drainageFacilityType(String facilityType) {
         String normalized = trimToNull(facilityType);
         if (normalized == null) {

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

@@ -10,6 +10,7 @@ import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
@@ -107,10 +108,12 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
             }
             String objectCode = trimToNull(pipe.getNetworkCode());
-            String industry = pipeIndustry(pipe.getNetworkType());
-            if (objectCode == null || industry == null) {
+            if (objectCode == null) {
                 continue;
             }
+            // 权威映射在 CockpitPipeNetworkType。无法映射的 network_type 交由 null 行业上行,
+            // 由服务层计入 excluded.unknownIndustryCount —— 不在适配器里静默丢弃(执行票 20)。
+            String industry = CockpitPipeNetworkType.industryOf(pipe.getNetworkType());
             // 管网设施无片区来源 → 未分区,不用 managementUnit 冒充。
             relations.add(relation(objectCode, "FACILITY", industry, null));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(pipe.getUpdateTime()));
@@ -151,32 +154,6 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
         return result;
     }
 
-    private static String pipeIndustry(String networkType) {
-        String normalized = trimToNull(networkType);
-        if (normalized == null) {
-            return null;
-        }
-        if ("2".equals(normalized)
-                || "WATER_SUPPLY".equalsIgnoreCase(normalized)
-                || "供水".equals(normalized)
-                || "供水管网".equals(normalized)) {
-            return "WATER_SUPPLY";
-        }
-        if ("3".equals(normalized)
-                || "GAS".equalsIgnoreCase(normalized)
-                || "燃气".equals(normalized)
-                || "燃气管网".equals(normalized)) {
-            return "GAS";
-        }
-        if ("DRAINAGE".equalsIgnoreCase(normalized)
-                || "SEWER".equalsIgnoreCase(normalized)
-                || "排水".equals(normalized)
-                || "排水管网".equals(normalized)) {
-            return "DRAINAGE";
-        }
-        return null;
-    }
-
     private static String format(LocalDateTime value) {
         return value == null
                 ? null

+ 6 - 31
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureOperatingInformationAdapter.java

@@ -8,6 +8,7 @@ import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
 import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
 import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.drainage.domain.EngineeringFacility;
 import com.zksy.drainage.service.EngineeringFacilityService;
 import org.springframework.context.annotation.Primary;
@@ -108,10 +109,9 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
             if (pipe == null || isBlank(pipe.getNetworkCode()) || pipe.getLength() == null) {
                 continue;
             }
-            String industry = pipeIndustry(pipe.getNetworkType());
-            if (industry == null) {
-                continue;
-            }
+            // 权威映射在 CockpitPipeNetworkType。无法映射的 network_type 交由 null 行业上行,
+            // 由服务层计入 excluded.unknownIndustryCount —— 不在适配器里静默丢弃(执行票 20)。
+            String industry = CockpitPipeNetworkType.industryOf(pipe.getNetworkType());
             String sampledAt = format(pipe.getUpdateTime());
             latestUpdatedAt = latest(latestUpdatedAt, sampledAt);
             metrics.add(metric(
@@ -188,32 +188,6 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
         return null;
     }
 
-    private static String pipeIndustry(String networkType) {
-        String normalized = trimToNull(networkType);
-        if (normalized == null) {
-            return null;
-        }
-        if ("2".equals(normalized)
-                || "WATER_SUPPLY".equalsIgnoreCase(normalized)
-                || "供水".equals(normalized)
-                || "供水管网".equals(normalized)) {
-            return "WATER_SUPPLY";
-        }
-        if ("GAS".equalsIgnoreCase(normalized)
-                || "3".equals(normalized)
-                || "燃气".equals(normalized)
-                || "燃气管网".equals(normalized)) {
-            return "GAS";
-        }
-        if ("DRAINAGE".equalsIgnoreCase(normalized)
-                || "排水".equals(normalized)
-                || "排水管网".equals(normalized)
-                || "SEWER".equalsIgnoreCase(normalized)) {
-            return "DRAINAGE";
-        }
-        return null;
-    }
-
     private static String drainageFacilityType(String facilityType) {
         String normalized = trimToNull(facilityType);
         if (normalized == null) {
@@ -231,8 +205,9 @@ public class BusinessCockpitInfrastructureOperatingInformationAdapter
         return null;
     }
 
+    /** 行业为空的未分类项用 {@code unknown_} 前缀,避免空指针并保持编码可读。 */
     private static String metricCode(String industry, String suffix) {
-        return industry.toLowerCase() + "_" + suffix;
+        return (industry == null ? "unknown" : industry.toLowerCase()) + "_" + suffix;
     }
 
     private static boolean abnormalStatus(String status) {

+ 10 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewInfrastructureAdapter.java

@@ -12,6 +12,7 @@ import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
@@ -104,10 +105,10 @@ public class BusinessCockpitOverviewInfrastructureAdapter
             if (facility == null) {
                 continue;
             }
-            String typeCode = trimToNull(facility.getNetworkType());
-            if (typeCode == null) {
-                continue;
-            }
+            // 权威映射在 CockpitPipeNetworkType(0-燃气/1-排水/2-供水)。此前这里直接把
+            // 原始 network_type 当类型码、且 industry 传 null,导致 5268 条管网整体被
+            // 服务层计入 excluded.unknownTypeCount(执行票 20)。
+            String typeCode = CockpitPipeNetworkType.typeCodeOf(facility.getNetworkType());
             FacilityAggregate aggregate = facilityByType.computeIfAbsent(typeCode, key -> new FacilityAggregate());
             aggregate.count++;
             if (facility.getLength() != null) {
@@ -119,8 +120,12 @@ public class BusinessCockpitOverviewInfrastructureAdapter
         for (Map.Entry<String, FacilityAggregate> entry : facilityByType.entrySet()) {
             String typeCode = entry.getKey();
             FacilityAggregate aggregate = entry.getValue();
+            // 行业由权威映射给出;未分类哨兵无行业,服务层据实计入 unknownTypeCount。
             sourceTypes.add(new CockpitOverviewInfrastructureSnapshot.SourceType(
-                    typeCode, typeCode, null, null, "FACILITY"));
+                    typeCode,
+                    CockpitPipeNetworkType.nameOf(typeCode),
+                    CockpitPipeNetworkType.industryOfTypeCode(typeCode),
+                    null, "FACILITY"));
             BigDecimal lengthKm = aggregate.lengthMeters == null
                     ? null : aggregate.lengthMeters.divide(BigDecimal.valueOf(1000), 3, RoundingMode.HALF_UP);
             counts.add(new CockpitOverviewInfrastructureSnapshot.SourceCount(