Преглед изворни кода

fix(cockpit): 管理信息端点下发排水雨污二级分项

管理信息此前只有行业级粒度,污水管段/雨水管段读不出单项数值。端点现于排水行下发 networkLevelCounts(按 pipe_network_base.network_level 拆 0-污水 1-雨水):设施目录仅对排水管线透传等级,服务层只对排水设施聚合并按码位升序输出。

约束:network_level 是跨行业共享字典(燃气侧为压力等级),故分项只挂 DRAINAGE 行、后端不给名字,码位语义由读端按行业字典解释;认不出的等级不进分项也不猜,仍留在 facilityCount,差值(泵站等非管线设施)如实可见;既有构造器与 totals 口径不变。

验收:后端19项测试通过;真实接口与前端计算复验污水1240、雨水2334(城南727/677、太常513/1657),6种行业与片区筛选一致。
Kazerin пре 3 дана
родитељ
комит
96906c80c8

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

@@ -52,6 +52,34 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
                         waterSourceInfoService, manholeBaseService));
     }
 
+    @Test
+    void drainagePipeCarriesNetworkLevelAndOtherIndustriesDoNot() {
+        // 排水管线:network_level 有雨污语义(0-污水 1-雨水),原样透传。
+        PipeNetworkBase drainagePipe = new PipeNetworkBase();
+        drainagePipe.setNetworkCode("PIPE-DR-001");
+        drainagePipe.setNetworkType("1");
+        drainagePipe.setNetworkLevel("1");
+
+        // 燃气管线:同一列在字典 `pipe_network_level` 里是压力等级,不得透传成雨污分项。
+        PipeNetworkBase gasPipe = new PipeNetworkBase();
+        gasPipe.setNetworkCode("PIPE-GAS-001");
+        gasPipe.setNetworkType("0");
+        gasPipe.setNetworkLevel("3");
+
+        when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(manholeBaseService.list()).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(drainagePipe, gasPipe));
+        when(waterSourceInfoService.list()).thenReturn(Collections.emptyList());
+
+        CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
+
+        assertEquals("1", relation(snapshot.getRelations(), "PIPE-DR-001").getNetworkLevel(),
+                "排水管线的二级分类应随关系透传,供服务层拆雨污");
+        assertNull(relation(snapshot.getRelations(), "PIPE-GAS-001").getNetworkLevel(),
+                "燃气的 network_level 是压力等级语义,不得冒充雨污分项");
+    }
+
     @Test
     void mapsIndustryAndDistrictFromAuthoritativeSources() {
         EquipmentType waterRoot = type(1, "供水", 0);

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

@@ -301,6 +301,39 @@ class CockpitInfrastructureManagementInformationControllerTest {
         }
     }
 
+    @Test
+    void drainageRowsCarrySewageAndRainwaterBreakdown() throws Exception {
+        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations = new ArrayList<>();
+        relations.add(relation("F-DR-S1", "FACILITY", "DRAINAGE", 0, "0"));
+        relations.add(relation("F-DR-S2", "FACILITY", "DRAINAGE", 0, "0"));
+        relations.add(relation("F-DR-R1", "FACILITY", "DRAINAGE", 0, "1"));
+        // 未标注等级的排水设施:留在 facilityCount,不进分项、不冒充任何一档。
+        relations.add(relation("F-DR-U1", "FACILITY", "DRAINAGE", 0, null));
+        relations.add(relation("F-DR-TC", "FACILITY", "DRAINAGE", 2, "1"));
+        // 燃气管同一列存的是压力等级(0-高压…),不得被当成雨污分项。
+        relations.add(relation("F-GAS-001", "FACILITY", "GAS", 0, "0"));
+        when(adapter.load()).thenReturn(new CockpitInfrastructureManagementInformationSnapshot(
+                "2026-09-02T09:59:50+08:00", relations));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                // 行序:片区升序 + 行业字母序 → (0,DRAINAGE)、(0,GAS)、(2,DRAINAGE)。
+                .andExpect(jsonPath("$.data.districtIndustry[0].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].facilityCount").value(4))
+                .andExpect(jsonPath("$.data.districtIndustry[0].networkLevelCounts", hasSize(2)))
+                .andExpect(jsonPath("$.data.districtIndustry[0].networkLevelCounts[0].networkLevel").value("0"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].networkLevelCounts[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.districtIndustry[0].networkLevelCounts[1].networkLevel").value("1"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].networkLevelCounts[1].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[1].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.districtIndustry[1].networkLevelCounts", hasSize(0)))
+                .andExpect(jsonPath("$.data.districtIndustry[2].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[2].networkLevelCounts", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtIndustry[2].networkLevelCounts[0].networkLevel").value("1"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].networkLevelCounts[0].facilityCount").value(1));
+    }
+
     /**
      * 片区×行业用例:
      * 城南 WSS 设施2/设备1;城北 GAS 设施1/设备1;未分区 DRAINAGE 设施1/设备1、
@@ -337,4 +370,15 @@ class CockpitInfrastructureManagementInformationControllerTest {
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
                 objectCode, objectKind, industry, district);
     }
+
+    /** 带 `network_level` 的关系构造:只有排水管线该列有雨污语义。 */
+    private static CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation(
+            String objectCode,
+            String objectKind,
+            String industry,
+            Integer district,
+            String networkLevel) {
+        return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
+                objectCode, objectKind, industry, district, networkLevel);
+    }
 }

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

@@ -102,7 +102,7 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
             LocalDate builtAt = pipe.getBuildDate() == null ? null
                     : new java.sql.Date(pipe.getBuildDate().getTime()).toLocalDate();
             add(facilities, key("pipe_network_base", pipe.getNetworkId(), pipe.getNetworkCode()), pipe.getNetworkCode(), CockpitPipeNetworkType.typeCodeOf(pipe.getNetworkType()),
-                    lengthKm, null, builtAt);
+                    lengthKm, null, builtAt, trimToNull(pipe.getNetworkLevel()));
         }
         for (EngineeringFacility facility : safeList(engineering.list())) {
             updatedAt = latest(updatedAt, facility.getUpdateTime());
@@ -129,6 +129,16 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
 
     private static void add(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities,
             String sourceKey, String code, String type, BigDecimal lengthKm, LocalDate commissionedAt, LocalDate builtAt) {
+        add(facilities, sourceKey, code, type, lengthKm, commissionedAt, builtAt, null);
+    }
+
+    /**
+     * `networkLevel` 只有**排水管线**带语义(0-污水 1-雨水):与 {@link #districtOf} 同一条纪律 ——
+     * 来源表没有该列的类别一律 {@code null},不给它们造值。
+     */
+    private static void add(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities,
+            String sourceKey, String code, String type, BigDecimal lengthKm, LocalDate commissionedAt, LocalDate builtAt,
+            String networkLevel) {
         String resolvedCode = trimToNull(code);
         if (resolvedCode == null) {
             // 缺少标识不能进入正式设施集合,但必须让三个服务都能复算排除数。
@@ -137,7 +147,8 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
         }
         facilities.add(new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
                 sourceKey == null ? resolvedCode : sourceKey, resolvedCode, type,
-                districtOf(resolvedCode, type), lengthKm, null, commissionedAt, builtAt));
+                districtOf(resolvedCode, type), lengthKm, null, commissionedAt, builtAt,
+                CockpitPipeNetworkType.DRAINAGE_PIPE.equals(type) ? networkLevel : null));
     }
 
     /**

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

@@ -62,9 +62,12 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 CockpitInfrastructureBaseArchivesSnapshot.SourceType type = facilityTypes.get(facility.getTypeCode());
                 // 来源表+主键的稳定身份:不同表同码不合并,同源重复关系仍可识别。
                 String rowKey = facility.getSourceKey();
+                // 雨污二级分类随目录原样透传:目录里只有排水管线带值,其余类别本就是 null,
+                // 不在这里判行业、也不在这里造值(判定归服务层,与 district 同一分层)。
                 relations.add(new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
                         rowKey, type == null ? "FACILITY" : type.getObjectKind(),
-                        type == null ? null : type.getIndustry(), facility.getDistrict()));
+                        type == null ? null : type.getIndustry(), facility.getDistrict(),
+                        facility.getNetworkLevel()));
             }
         }
         return new CockpitInfrastructureManagementInformationSnapshot(updatedAt, relations);

+ 16 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java

@@ -58,6 +58,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
         private final String scale;
         private final LocalDate commissionedAt;
         private final LocalDate builtAt;
+        private final String networkLevel;
 
         public SourceFacility(
                 String code,
@@ -67,11 +68,22 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
                 String scale,
                 LocalDate commissionedAt,
                 LocalDate builtAt) {
-            this(typeCode + "|" + code, code, typeCode, district, lengthKm, scale, commissionedAt, builtAt);
+            this(typeCode + "|" + code, code, typeCode, district, lengthKm, scale, commissionedAt, builtAt, null);
         }
 
         public SourceFacility(String sourceKey, String code, String typeCode, Integer district,
                 BigDecimal lengthKm, String scale, LocalDate commissionedAt, LocalDate builtAt) {
+            this(sourceKey, code, typeCode, district, lengthKm, scale, commissionedAt, builtAt, null);
+        }
+
+        /**
+         * `networkLevel` 是 `pipe_network_base.network_level` 的**原始值**:排水管线专用
+         * (0-污水、1-雨水)。非管线类别没有该语义,一律 {@code null} —— 与 `district` 同一条纪律:
+         * 没有可信来源就不填,由读端如实处理,不猜。
+         */
+        public SourceFacility(String sourceKey, String code, String typeCode, Integer district,
+                BigDecimal lengthKm, String scale, LocalDate commissionedAt, LocalDate builtAt,
+                String networkLevel) {
             this.sourceKey = sourceKey;
             this.code = code;
             this.typeCode = typeCode;
@@ -80,6 +92,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
             this.scale = scale;
             this.commissionedAt = commissionedAt;
             this.builtAt = builtAt;
+            this.networkLevel = networkLevel;
         }
 
         public String getSourceKey() { return sourceKey; }
@@ -90,5 +103,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
         public String getScale() { return scale; }
         public LocalDate getCommissionedAt() { return commissionedAt; }
         public LocalDate getBuiltAt() { return builtAt; }
+        /** 排水管线二级分类原始值(0-污水 1-雨水);非管线类别为 {@code null}。 */
+        public String getNetworkLevel() { return networkLevel; }
     }
 }

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

@@ -2,6 +2,8 @@ package com.zksy.cockpit.infrastructure;
 
 import com.zksy.cockpit.foundation.CockpitDistrict;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -135,6 +137,11 @@ public class CockpitInfrastructureManagementInformationResponse {
      *
      * <p>片区用编码 + 中文名双字段:未分区(设施无片区来源)回 {@code district = null}
      * 与字面量「未分区」,不猜、不按行业或权属单位冒充(决策票 06)。
+     *
+     * <p>`networkLevelCounts` 是该桶内排水设施按 `network_level` 的**二级分项**:
+     * 只有 {@code industry = DRAINAGE} 的行可能有值,其余行恒为空数组。
+     * 分项只覆盖**能认出等级**的设施,分项之和可小于该行 `facilityCount`
+     * (差值=未标/认不出等级的设施),不强行补齐成整行。
      */
     public static class DistrictIndustryCount {
         private final Integer district;
@@ -142,14 +149,24 @@ public class CockpitInfrastructureManagementInformationResponse {
         private final String industry;
         private final int facilityCount;
         private final int equipmentCount;
+        private final List<NetworkLevelCount> networkLevelCounts;
 
         public DistrictIndustryCount(
                 Integer district, String industry, int facilityCount, int equipmentCount) {
+            this(district, industry, facilityCount, equipmentCount, Collections.emptyList());
+        }
+
+        public DistrictIndustryCount(
+                Integer district, String industry, int facilityCount, int equipmentCount,
+                List<NetworkLevelCount> networkLevelCounts) {
             this.district = CockpitDistrict.codeOf(district);
             this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.facilityCount = facilityCount;
             this.equipmentCount = equipmentCount;
+            this.networkLevelCounts = networkLevelCounts == null
+                    ? Collections.emptyList()
+                    : Collections.unmodifiableList(new ArrayList<>(networkLevelCounts));
         }
 
         /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
@@ -159,6 +176,30 @@ public class CockpitInfrastructureManagementInformationResponse {
         public String getIndustry() { return industry; }
         public int getFacilityCount() { return facilityCount; }
         public int getEquipmentCount() { return equipmentCount; }
+        /** 排水二级分项(非排水行业为空数组);按等级码升序。 */
+        public List<NetworkLevelCount> getNetworkLevelCounts() { return networkLevelCounts; }
+    }
+
+    /**
+     * 排水设施的一个 `network_level` 分项。
+     *
+     * <p>**只给码、不给名**:`pipe_network_base.network_level` 存的是共享字典
+     * `pipe_network_level` 的编码,该字典对**燃气**是压力等级(0-高压…),对**排水**才是
+     * 雨污(0-污水 1-雨水)—— 同一列名跨行业不同语义(同「设施不得借用设备类型命名空间」
+     * 那类坑)。因此后端不在这里挂一个跨行业通用的名字,由读端按行业字典解释码位。
+     */
+    public static class NetworkLevelCount {
+        private final String networkLevel;
+        private final int facilityCount;
+
+        public NetworkLevelCount(String networkLevel, int facilityCount) {
+            this.networkLevel = networkLevel;
+            this.facilityCount = facilityCount;
+        }
+
+        /** `network_level` 原始码(排水:0-污水 1-雨水)。 */
+        public String getNetworkLevel() { return networkLevel; }
+        public int getFacilityCount() { return facilityCount; }
     }
 
     /** 生效范围回显;组织维度已删除,保留行业与片区,口径与 {@link SelectedFilters} 一致。 */

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

@@ -38,6 +38,10 @@ public class CockpitInfrastructureManagementInformationService {
     private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String FACILITY = "FACILITY";
     private static final String SMART_DEVICE = "SMART_DEVICE";
+    /** 排水行业码:`network_level` 二级分项只挂在这一行业的设施桶上。 */
+    private static final String DRAINAGE = "DRAINAGE";
+    /** 排水二级分类的**可解释码位**(0-污水 1-雨水),按码升序输出;其它码不进分项。 */
+    private static final List<String> DRAINAGE_LEVELS = Arrays.asList("0", "1");
 
     private final CockpitInfrastructureManagementInformationAdapter adapter;
     private final Clock clock;
@@ -104,8 +108,14 @@ public class CockpitInfrastructureManagementInformationService {
             }
             DistrictIndustryKey key = new DistrictIndustryKey(
                     CockpitDistrict.codeOf(relation.getDistrict()), industryCode);
-            counters.computeIfAbsent(key, ignored -> new Counter())
-                    .add(FACILITY.equals(kind));
+            Counter counter = counters.computeIfAbsent(key, ignored -> new Counter());
+            counter.add(FACILITY.equals(kind));
+            // 雨污二级分项只对**排水设施**有意义:`network_level` 是跨行业共享列(字典
+            // `pipe_network_level`),燃气侧同一列存的是压力等级,不能拿燃气的码来拆雨污。
+            // 设备没有该列;认不出的码不猜、不进分项,仍留在 facilityCount 里如实可见。
+            if (FACILITY.equals(kind) && DRAINAGE.equals(industryCode)) {
+                counter.addDrainageLevel(drainageLevelOf(relation.getNetworkLevel()));
+            }
         }
 
         List<DistrictIndustryCount> districtIndustry = new ArrayList<>();
@@ -117,7 +127,8 @@ public class CockpitInfrastructureManagementInformationService {
             facilityCount += counter.facilityCount;
             equipmentCount += counter.equipmentCount;
             districtIndustry.add(new DistrictIndustryCount(
-                    key.district, key.industry, counter.facilityCount, counter.equipmentCount));
+                    key.district, key.industry, counter.facilityCount, counter.equipmentCount,
+                    counter.drainageLevelCounts()));
         }
         districtIndustry.sort(Comparator
                 .comparing(DistrictIndustryCount::getDistrict,
@@ -241,9 +252,25 @@ public class CockpitInfrastructureManagementInformationService {
         }
     }
 
+    /**
+     * 排水 `network_level` → 可解释码位:只认 {@code 0-污水 / 1-雨水}。
+     *
+     * <p>其余值(空、脏码、燃气压力等级语义)一律回 {@code null}=不进分项 —— 该设施仍计在
+     * 行的 `facilityCount` 里,差值在面板上如实可见,不靠猜码补齐。
+     */
+    private static String drainageLevelOf(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String value = raw.trim();
+        return DRAINAGE_LEVELS.contains(value) ? value : null;
+    }
+
     private static final class Counter {
         private int facilityCount;
         private int equipmentCount;
+        /** 同一桶内按 `network_level` 码位累计的设施数;只对排水设施调用。 */
+        private final Map<String, Integer> drainageLevels = new LinkedHashMap<>();
 
         private void add(boolean facility) {
             if (facility) {
@@ -252,5 +279,23 @@ public class CockpitInfrastructureManagementInformationService {
                 equipmentCount++;
             }
         }
+
+        private void addDrainageLevel(String level) {
+            if (level != null) {
+                drainageLevels.merge(level, 1, Integer::sum);
+            }
+        }
+
+        /** 固定码位升序输出,只带真实出现的档位(0 与空分得开:0 是「确认没有」)。 */
+        private List<CockpitInfrastructureManagementInformationResponse.NetworkLevelCount> drainageLevelCounts() {
+            List<CockpitInfrastructureManagementInformationResponse.NetworkLevelCount> counts = new ArrayList<>();
+            for (String level : DRAINAGE_LEVELS) {
+                Integer count = drainageLevels.get(level);
+                if (count != null && count > 0) {
+                    counts.add(new CockpitInfrastructureManagementInformationResponse.NetworkLevelCount(level, count));
+                }
+            }
+            return counts;
+        }
     }
 }

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

@@ -17,34 +17,51 @@ public class CockpitInfrastructureManagementInformationSnapshot {
     public List<SourceRelation> getRelations() { return relations; }
 
     /**
-     * 一条对象记录:对象、所属行业、所属片区。
+     * 一条对象记录:对象、所属行业、所属片区、排水管线的二级分类。
      *
      * <p>组织/权属字段(`organizationCode` / `organizationName` / `ownershipType`)已随决策票 06
      * 删除:它们只服务被删的「组织 × 权属」视图,留着会重新诱导出编造归属。
      *
      * <p>`district` 是**原始片区编码**(未归一见 {@code CockpitDistrict}),权威来源
      * `equipment_base.district`;设施没有该来源时传 {@code null},由服务层如实归「未分区」。
+     *
+     * <p>`networkLevel` 是 `pipe_network_base.network_level` 的**原始值**:只有排水管线带语义
+     * (0-污水、1-雨水),其它行业与非管线设施一律 {@code null} —— 服务层据此把非排水对象
+     * 排除在雨污分项之外,不给它们造分项。
      */
     public static class SourceRelation {
         private final String objectCode;
         private final String objectKind;
         private final String industry;
         private final Integer district;
+        private final String networkLevel;
 
         public SourceRelation(
                 String objectCode,
                 String objectKind,
                 String industry,
                 Integer district) {
+            this(objectCode, objectKind, industry, district, null);
+        }
+
+        public SourceRelation(
+                String objectCode,
+                String objectKind,
+                String industry,
+                Integer district,
+                String networkLevel) {
             this.objectCode = objectCode;
             this.objectKind = objectKind;
             this.industry = industry;
             this.district = district;
+            this.networkLevel = networkLevel;
         }
 
         public String getObjectCode() { return objectCode; }
         public String getObjectKind() { return objectKind; }
         public String getIndustry() { return industry; }
         public Integer getDistrict() { return district; }
+        /** 排水管线二级分类原始值(0-污水 1-雨水);非管线设施与其它行业为 {@code null}。 */
+        public String getNetworkLevel() { return networkLevel; }
     }
 }