Quellcode durchsuchen

fix(cockpit): 设施片区按管线编码前缀推导 + 监测分布八桶随筛选收窄 (t81)

缺陷 A:BusinessCockpitInfrastructureBaseArchivesAdapter 构建设施行时片区恒为 null,
基础档案「按片区筛设施总数」永远筛不出,管理信息 districtIndustry 全落未分区。
片区唯一来源取设施编码前缀(CockpitDistrictPrefix,票 67 §十):管线 6762 条零反例
(CN/TC),但水厂 WP001、泵站 PS001、窨井 YL-YJ-001、排水设施 facilityId(UUID)
都没有片区语义,故按 typeCode 门控、只给管线填值,其余如实 null(UUID 以 cb 开头
会被误判成城北,正是一刀切不能用的理由)。实测预期:城南 2646 / 城北 0 /
太常 4116 / 未分区 14,合计 6776 = totals.facilityCount。

缺陷 B:CockpitMonitoringDistributionService 的 global.addPoint /
addUnassociatedDevice 在 matchesFilters 之前累计,导致 pointStatusDistribution
恒为全量、不随片区/行业变化。改为在片区/行业/设备类型过滤之后、状态过滤之前累计:
分布随筛选收窄,但不因用户选中某个状态塌成一根。map-snapshot 的全局全集口径
有意保留,未改动。

测试:新增「八桶与片区分布同一 scope」不变式用例(六态+UNKNOWN = 片区 pointCount
之和;再加 UNASSOCIATED = 台账 total)与设施片区前缀用例(含 cb 开头 UUID 反例);
Cockpit*Test 487 → 488。
Kazerin vor 1 Woche
Ursprung
Commit
3c82eb51ff

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

@@ -85,9 +85,10 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         assertEquals(4, snapshot.getFacilities().size());
         assertEquals(10, snapshot.getTypes().size());
         assertEquals("DRAINAGE_STORAGE", snapshot.getTypes().get(6).getCode());
-        // 设施表都没有 equipment_base.district 那种权威片区来源,全部如实报未分区。
+        // 设施表都没有 equipment_base.district 那种权威片区来源;本夹具的编码(PLANT-001/
+        // PUMP-001/PIPE-001/DRAIN-001)都不带片区前缀,故全部如实报未分区(票 81)。
         assertTrue(snapshot.getFacilities().stream().allMatch(item -> item.getDistrict() == null),
-                "设施缺少片区来源时一律报 null,不得编造片区");
+                "编码推不出片区时一律报 null,不得编造片区");
         CockpitInfrastructureBaseArchivesSnapshot.SourceFacility plantFacility =
                 snapshot.getFacilities().get(0);
         assertEquals("PLANT-001", plantFacility.getCode());
@@ -161,6 +162,53 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         assertFalse(CockpitPipeNetworkType.isFormal("PIPE_UNCLASSIFIED"));
     }
 
+    /**
+     * 票 81:片区只从**管线编码前缀**推导(CN=城南 / CB=城北 / TC=太常,大小写不敏感)。
+     *
+     * <p>本用例同时钉住边界:非管线设施即便编码以 {@code cb} 开头也**不得**被当成城北 ——
+     * 排水工程设施拿的是 {@code facilityId}(UUID/十六进制),前缀巧合会造成假片区。
+     */
+    @Test
+    void derivesDistrictFromPipeCodePrefixOnlyAndNeverFromOtherFacilityTypes() {
+        WaterPlantInfo plant = new WaterPlantInfo();
+        plant.setPlantCode("WP001");
+        WaterPumpStation pump = new WaterPumpStation();
+        pump.setStationCode("PS001");
+        pump.setStationType(PumpStationType.WATER_SUPPLY);
+        EngineeringFacility drainage = new EngineeringFacility();
+        // 反例:facilityId 是 UUID(十六进制),cb 开头;它没有片区语义,必须留 null。
+        drainage.setFacilityId("cb1234a2e4406485d85ce1b0db51427");
+        drainage.setFacilityType("2");
+
+        when(plantService.list()).thenReturn(Collections.singletonList(plant));
+        when(pumpStationService.list()).thenReturn(Collections.singletonList(pump));
+        when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(
+                pipe("CNWS202609080732", "城南排水管线", "1", "1000"),
+                pipe("cbGC202609080001", "城北供水管线", "2", "1500"),
+                pipe("TCRQ202609080001", "太常燃气管线", "0", "500")));
+        when(engineeringFacilityService.list()).thenReturn(Collections.singletonList(drainage));
+
+        List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities = adapter.load().getFacilities();
+
+        assertEquals(Integer.valueOf(0), facility(facilities, "CNWS202609080732").getDistrict(), "CN → 城南");
+        assertEquals(Integer.valueOf(1), facility(facilities, "cbGC202609080001").getDistrict(), "CB 小写也应识别");
+        assertEquals(Integer.valueOf(2), facility(facilities, "TCRQ202609080001").getDistrict(), "TC → 太常");
+        assertNull(facility(facilities, "WP001").getDistrict(), "水厂编码无片区语义,不得冒充");
+        assertNull(facility(facilities, "PS001").getDistrict(), "泵站编码无片区语义,不得冒充");
+        assertNull(facility(facilities, "cb1234a2e4406485d85ce1b0db51427").getDistrict(),
+                "UUID 以 cb 开头也不能冒充城北");
+        assertEquals(3, facilities.stream().filter(item -> item.getDistrict() != null).count(),
+                "只有三条管线应带片区");
+    }
+
+    private static CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility(
+            List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities, String code) {
+        return facilities.stream()
+                .filter(item -> code.equals(item.getCode()))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("缺少设施:" + code));
+    }
+
     private static PipeNetworkBase pipe(String code, String name, String type, String length) {
         PipeNetworkBase pipe = new PipeNetworkBase();
         pipe.setNetworkCode(code);

+ 77 - 8
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -8,6 +8,7 @@ import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
 import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionSnapshot;
 import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionResponse;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -107,12 +108,18 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
+                // 票 81:八桶与三份分布同一 scope,被 classify() 排除的 P-MISMATCH / P-UNKNOWN
+                // (原本各计 1 个 NORMAL)不再进状态桶 —— 它们已由 excluded 三个计数器如实报数。
+                // 有效 5 点位是 WORK_ORDER/EMPTY/ALARM/OFFLINE/WARNING,故 NORMAL 回到 0。
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
+                // 设备侧同口径:EQ-FREE-004(智能井盖、无行业)被排除,UNASSOCIATED 只数剩下 4 台。
+                .andExpect(jsonPath("$.data.pointStatusDistribution[7].status").value("UNASSOCIATED"))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[7].count").value(4))
                 .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
                 .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
@@ -175,9 +182,13 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
+                // 票 81 前这条断言钉的是「全量」:GAS 范围内根本没有 NORMAL 点位,
+                // 却因为 global 在过滤前累计而恒读 2(旧值来自被排除的两个无类型/错种类点)。
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
+                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(0))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
+                // GAS 范围内未关联设备只剩 EQ-FREE-001 / EQ-FREE-005 两台。
+                .andExpect(jsonPath("$.data.pointStatusDistribution[7].count").value(2));
     }
 
     @Test
@@ -255,7 +266,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
+                // 票 81:分布随设备类型收窄,此 scope 空 ⇒ 八桶必须全 0;
+                // 旧断言读 1 是 global 在过滤前累计留下的全量残值。
+                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
@@ -379,7 +392,9 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse initial = service.load(
                 "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
         assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
+        // 票 81:基线 NORMAL 从 2 变 0 —— 那 2 个是被排除的 P-MISMATCH / P-UNKNOWN;
+        // 本用例真正要钉的是「增量 +1 能被复算」,故期望值随之整体下移一位。
+        assertEquals(0, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -388,7 +403,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:05:00+08:00"));
 
         assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -397,7 +412,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:04:00+08:00"));
 
         assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -406,7 +421,42 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:06:00+08:00"));
 
         assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
+    }
+
+    /**
+     * 票 81:八桶必须与片区/行业/设备类型分布描述**同一个 scope**,且不因状态筛选塌成一根。
+     *
+     * <p>下面两条等式把「累加位置」钉死 —— 谁把 {@code global.addPoint}/
+     * {@code addUnassociatedDevice} 挪回过滤之前(或挪到状态过滤之后),它们都会立刻变红:
+     * ①六态 + UNKNOWN 只数点位,故必须等于片区分布 {@code pointCount} 的跨片区求和;
+     * ②再加 UNASSOCIATED 就是整张台账,故必须等于分页 {@code total}。
+     */
+    @Test
+    void eightBucketsShareTheSameScopeAsDistributionsAndNeverCollapseUnderStatusFilter() {
+        for (String[] scope : Arrays.asList(
+                new String[]{null, null, null},
+                new String[]{"GAS", null, null},
+                new String[]{null, "GAS_SENSOR", null},
+                new String[]{null, null, "1"})) {
+            CockpitMonitoringDistributionResponse response =
+                    service.load("scope", scope[0], scope[1], null, scope[2], 1, 100, null);
+            String scopeText = "[industry=" + scope[0] + " deviceType=" + scope[1]
+                    + " district=" + scope[2] + "]";
+            assertEquals(districtPointSum(response), statusBucketSum(response, true),
+                    "八桶的点位部分与片区分布 scope 不一致:" + scopeText);
+            assertEquals(response.getPagination().getTotal(), statusBucketSum(response, false),
+                    "八桶合计与台账总数不一致:" + scopeText);
+        }
+
+        // 状态是「选中项」不是「收窄项」:选了某个状态,八桶仍读同一 scope,不得塌成一根。
+        int unscoped = statusBucketSum(
+                service.load("all", null, null, null, null, 1, 100, null), false);
+        for (String status : Arrays.asList("ALARM", "NORMAL", "UNASSOCIATED")) {
+            int scoped = statusBucketSum(
+                    service.load("status", null, null, status, null, 1, 100, null), false);
+            assertEquals(unscoped, scoped, "状态筛选把八桶收窄了:" + status);
+        }
     }
 
     /**
@@ -529,6 +579,25 @@ class CockpitMonitoringDistributionControllerTest {
                 .getCount();
     }
 
+    /** 六态 + UNKNOWN(index 0-6)只数点位;加上 UNASSOCIATED(index 7)才是整张台账。 */
+    private int statusBucketSum(CockpitMonitoringDistributionResponse response, boolean pointsOnly) {
+        List<CockpitPointStatusQuery.Count> rows = response.getPointStatusDistribution();
+        int sum = 0;
+        for (int index = 0; index < (pointsOnly ? 7 : 8); index++) {
+            sum += rows.get(index).getCount();
+        }
+        return sum;
+    }
+
+    private int districtPointSum(CockpitMonitoringDistributionResponse response) {
+        int sum = 0;
+        for (CockpitMonitoringDistributionResponse.DistrictDistribution row
+                : response.getDistrictDistribution()) {
+            sum += row.getPointCount();
+        }
+        return sum;
+    }
+
     private boolean hasRequestMappingAnnotation(Method method) {
         return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
                 || method.isAnnotationPresent(GetMapping.class)

+ 35 - 3
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java

@@ -63,8 +63,13 @@ class CockpitPointStatusContractTest {
                 read(DISTRIBUTION, "WARNING", "ALARM").path("mapFilter"));
     }
 
+    /**
+     * 八桶不得重复计设备、不得随翻页/状态筛选抖动;但**两个端点的 scope 口径不同**(票 81):
+     * map-snapshot 有意返回全局全集(服务层显式不传筛选),monitoring/distribution 的八桶
+     * 与它的三份分布同一 scope —— 所以空 scope 下 distribution 必须是全 0,map 仍是全集。
+     */
     @Test
-    void globalEightBucketsDoNotChangeWithAnyFilterOrPageAndNeverDoubleCountDevices() throws Exception {
+    void eightBucketsNeverDoubleCountDevicesAndDistributionNarrowsWithScope() throws Exception {
         JsonNode baseline = read(MAP);
         JsonNode counts = baseline.path("pointStatusDistribution");
         assertEquals(8, counts.size());
@@ -84,10 +89,31 @@ class CockpitPointStatusContractTest {
                     .param("industry", "GAS").param("district", "0").param("deviceType", "GAS_SENSOR")
                     .param("pageSize", "1").param("page", "2").param("snapshotAt", TOKEN));
             assertEquals(2, total(filtered));
-            assertEquals(counts, filtered.path("pointStatusDistribution"));
+            if (DISTRIBUTION.equals(endpoint)) {
+                // 票 81:八桶随片区/行业/设备类型收窄 —— 该 scope 里 P-E 没有设备类型,
+                // 已被 deviceType 过滤掉,故 EMPTY 归零;其余桶与全集值相同。
+                JsonNode scoped = filtered.path("pointStatusDistribution");
+                assertEquals(1, count(scoped, "ALARM"));
+                assertEquals(1, count(scoped, "WARNING"));
+                assertEquals(1, count(scoped, "NORMAL"));
+                assertEquals(0, count(scoped, "EMPTY"));
+                assertEquals(4, count(scoped, "UNASSOCIATED"));
+                // 状态是「选中项」不是「收窄项」:同一 scope 去掉 pointStatus 后八桶必须逐字节相同。
+                JsonNode sameScopeNoStatus = response(get(endpoint)
+                        .param("industry", "GAS").param("district", "0")
+                        .param("deviceType", "GAS_SENSOR"));
+                assertEquals(sameScopeNoStatus.path("pointStatusDistribution"), scoped);
+            } else {
+                assertEquals(counts, filtered.path("pointStatusDistribution"));
+            }
             JsonNode empty = response(get(endpoint).param("deviceType", "NOT_A_TYPE"));
             assertEquals(0, total(empty));
-            assertEquals(counts, empty.path("pointStatusDistribution"));
+            if (DISTRIBUTION.equals(endpoint)) {
+                // 票 81 前这里也能读到全局全集;修后八桶随 scope 收窄,空 scope 必为全 0。
+                assertEquals(0, bucketSum(empty.path("pointStatusDistribution")));
+            } else {
+                assertEquals(counts, empty.path("pointStatusDistribution"));
+            }
         }
     }
 
@@ -146,6 +172,12 @@ class CockpitPointStatusContractTest {
         return data.has("pagination") ? data.path("pagination").path("total").asInt() : data.path("total").asInt();
     }
 
+    private static int bucketSum(JsonNode rows) {
+        int sum = 0;
+        for (JsonNode row : rows) sum += row.path("count").asInt();
+        return sum;
+    }
+
     private static int count(JsonNode rows, String status) {
         for (JsonNode row : rows) if (status.equals(row.path("status").asText())) return row.path("count").asInt();
         throw new AssertionError("Missing bucket: " + status);

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

@@ -11,6 +11,7 @@ import com.zksy.base.domain.ManholeBase;
 import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
 import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.drainage.domain.EngineeringFacility;
 import com.zksy.drainage.service.EngineeringFacilityService;
@@ -32,7 +33,8 @@ import java.util.stream.Collectors;
 /**
  * 三个驾驶舱设施统计端点的权威设施目录(执行票 31)。
  * 包含管网、水厂、供排水泵站、排水工程设施、水源地与窨井的合法记录并集。
- * 设施无权威片区来源,一律保持 null;未知类型或缺编码行以哨兵保留到 excluded。
+ * 片区只由**管线编码前缀**推出(见 {@link #districtOf}),其余设施没有可信来源、如实保持 null;
+ * 未知类型或缺编码行以哨兵保留到 excluded。
  */
 @Component
 @Primary
@@ -51,6 +53,9 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
             type("DRAINAGE_PUMP_STATION", "排水泵站", "DRAINAGE"),
             type("WATER_SOURCE", "水源地", "WATER_SUPPLY"),
             type("MANHOLE", "窨井", "MANHOLE"));
+    /** 编码带片区前缀的类型:仅 {@code pipe_network_base} 的三种管线。 */
+    private static final List<String> PIPE_TYPES = Arrays.asList(
+            CockpitPipeNetworkType.WATER_PIPE, CockpitPipeNetworkType.GAS_PIPE, CockpitPipeNetworkType.DRAINAGE_PIPE);
 
     private final IWaterPlantInfoService plants;
     private final IWaterPumpStationService stations;
@@ -131,7 +136,24 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
             type = UNCLASSIFIED;
         }
         facilities.add(new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
-                sourceKey == null ? resolvedCode : sourceKey, resolvedCode, type, null, lengthKm, null, commissionedAt, builtAt));
+                sourceKey == null ? resolvedCode : sourceKey, resolvedCode, type,
+                districtOf(resolvedCode, type), lengthKm, null, commissionedAt, builtAt));
+    }
+
+    /**
+     * 设施片区(票 81):**只对管线**按编码前缀推导。
+     *
+     * <p>依据:票 67 §十 裁定「管线编码 = 片区 + 行业 + 时间编码」,现网 6762 条管线零反例
+     * (CN/TC)。管线之外没有这套编码 —— 水厂 WP001、泵站 PS001/GIS-TEST-PS-001、窨井
+     * YL-YJ-001 都不是片区码;排水工程设施甚至拿 facilityId(UUID,十六进制)当编码,
+     * 硬套前缀会把 cb 开头的 UUID 误判成城北。故这些类别如实留 {@code null}=未分区,
+     * 不按行业或地址冒充(唯一映射见 {@link CockpitDistrictPrefix})。
+     */
+    private static Integer districtOf(String resolvedCode, String typeCode) {
+        if (!PIPE_TYPES.contains(typeCode)) {
+            return null;
+        }
+        return CockpitDistrictPrefix.codeOf(resolvedCode);
     }
 
     private static String key(String table, Object id, String code) {

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

@@ -47,8 +47,8 @@ public class CockpitInfrastructureBaseArchivesService {
      * `selectedFilters` 与 `mapFilter` 回显实际生效范围。
      *
      * <p>范围维度自执行票 12 起为**片区**:只认 {@code 0/1/2/UNASSIGNED},其它值 400;
-     * 旧自由串 `region` 不再是正式契约,入参被忽略。设施表没有片区来源,故实际全部落
-     * 「未分区」——这是如实结果,不是缺陷。
+     * 旧自由串 `region` 不再是正式契约,入参被忽略。片区来源是**管线编码前缀**(票 81,
+     * 唯一映射见 {@code CockpitDistrictPrefix});无前缀的设施如实落「未分区」。
      */
     public CockpitInfrastructureBaseArchivesResponse load(
             String requestId,
@@ -114,7 +114,7 @@ public class CockpitInfrastructureBaseArchivesService {
         int commissionedCount = 0;
         int builtCount = 0;
         int unknownDateCount = 0;
-        // 按归一后的片区编码分桶;null 键=未分区(设施无片区来源,如实归入此桶)。
+        // 按归一后的片区编码分桶;null 键=未分区(没有片区前缀的设施如实归入此桶)。
         Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
 
         for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : facilities) {

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

@@ -45,8 +45,9 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
      * 一条设施来源行。
      *
      * <p>{@code district} 是片区**原始编码**(未归一见 {@code CockpitDistrict}):
-     * 现网设施表(水厂/泵站/管网/排水设施)没有片区列、也不关联设备,因此适配器一律传
-     * {@code null},由服务层如实归一为「未分区」,不用行业或管理单位冒充。
+     * 设施表没有片区列,唯一可信来源是**管线编码前缀**(票 67 §十),适配器只为管线填值;
+     * 其余类别(水厂/泵站/排水设施/水源地/窨井)没有片区语义,如实传 {@code null},
+     * 由服务层归一为「未分区」,不用行业或管理单位冒充。
      */
     public static class SourceFacility {
         private final String sourceKey;

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

@@ -35,7 +35,8 @@ import java.util.Set;
  * Read-only aggregation for 运行监测 / 监测分布.
  *
  * <p>业务对象参与计数、台账与筛选,坐标校验仅决定能否上图。
- * 八桶分布取全局未筛全集(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)。
+ * 八桶分布(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)与三份分布同一 scope:
+ * 随片区/行业/设备类型收窄,但**不随状态筛选塌成一根**(筛选器与分布图的标准关系)。
  */
 @Service
 public class CockpitMonitoringDistributionService {
@@ -264,7 +265,6 @@ public class CockpitMonitoringDistributionService {
                     effective.add(effectiveDevice(device));
                 }
                 CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
-                global.addPoint(status);
                 String mismatch = classify(type);
                 boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && effective.isEmpty();
                 if (mismatch != null && !emptyPointWithUnknownType) {
@@ -277,8 +277,11 @@ public class CockpitMonitoringDistributionService {
                 String markerTypeCode = point.getTypeCode();
                 Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
                 if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
-                        markerIndustry, point.getTypeCode(), markerDistrict, type)
-                        || !selectedStatus.matchesPoint(status)) continue;
+                        markerIndustry, point.getTypeCode(), markerDistrict, type)) continue;
+                // 票 81:先按片区/行业/设备类型收窄再计数,但要在**状态过滤之前** ——
+                // 否则用户选中某个状态时,分布图会塌成一根,失去筛选器语义。
+                global.addPoint(status);
+                if (!selectedStatus.matchesPoint(status)) continue;
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
                 deviceCodes.addAll(codes(effective));
@@ -307,7 +310,6 @@ public class CockpitMonitoringDistributionService {
                     continue;
                 }
                 if (associatedCodes.contains(device.getDeviceCode().trim())) continue;
-                global.addUnassociatedDevice();
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
                 String mismatch = classify(type);
                 if (mismatch != null) {
@@ -321,6 +323,9 @@ public class CockpitMonitoringDistributionService {
                 Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
                 if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
                         markerIndustry, device.getTypeCode(), markerDistrict, type)) continue;
+                // 与点位侧同口径:未关联设备计入 UNASSOCIATED 也须在片区/行业/设备类型之后、
+                // 状态过滤之前,两端不得一个收窄一个不收窄。
+                global.addUnassociatedDevice();
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(