Explorar el Código

fix(cockpit): admit coordinates by primary-area rule instead of manual verification

Cancel the manual coordinate-verification gate: the database has no
coordinate_verified column, no admin entry can set it, and every real
adapter passes false, so all 5272 geometry features and all 15 map objects
were permanently unrenderable.

Judge by structure plus location instead: missing/out-of-range/zero
coordinates keep their specific INVALID reasons, numerically valid
coordinates outside map.primaryArea.bounds get the new
OUTSIDE_PRIMARY_AREA reason, and the rest become VALID/renderable.
Structural errors still take precedence, so no illegal coordinate is
admitted. The coordinateVerified parameter stays for call-site
compatibility but no longer affects the outcome.
codex hace 1 semana
padre
commit
f33ef5708c
Se han modificado 15 ficheros con 385 adiciones y 133 borrados
  1. 13 9
      docs/cockpit/README.md
  2. 5 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  3. 7 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java
  4. 1 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java
  5. 5 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringPointDetailAdapterTest.java
  6. 161 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCoordinateValidatorTest.java
  7. 16 14
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java
  8. 15 14
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryControllerTest.java
  9. 46 21
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryServiceTest.java
  10. 11 9
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  11. 8 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  12. 11 9
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailControllerTest.java
  13. 35 30
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailSnapshotContractTest.java
  14. 11 10
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  15. 40 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java

+ 13 - 9
docs/cockpit/README.md

@@ -85,13 +85,17 @@
 
 - `page` 默认 1;`pageSize` 默认 100(地图快照默认 500),上限 1000;越界返回 `INVALID_REQUEST`。
 - `map-snapshot` 的 `page > 1` 必须携带**首屏无 token 请求签发**的同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;显式 token 仅复用已签发快照,缓存未命中不得按该时间重读当前库。同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
-- 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
+- 坐标不可上图的对象(结构非法,或落在主场景区外)保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
+- **坐标准入(2026-10-12 维护者裁定,取代此前的「人工校核前置」)**:入库坐标即视为可信来源,判据为「结构合法 + 落在主场景区范围内」。判定顺序:缺经度/缺纬度/经度越界/纬度越界/零坐标 → 各自的 `INVALID` 原因;数值合法但落在主场景区外 → `INVALID` / `OUTSIDE_PRIMARY_AREA`;其余 → `VALID` / `renderable=true`。
+  - 主场景区闭区间(与前端部署配置 `map.primaryArea.bounds` 一致):`west=110.328749941417, south=28.3917230659234, east=110.465779226663, north=28.4913038992907`,常量与 `insidePrimaryArea()` 收在 `CockpitCoordinateValidator` 一处。
+  - **`coordinateVerified` 入参保留但不再影响结论**:现网既无可信校核字段也无校核入口,继续把它当前置门槛会让全部真实对象恒不可上图(实测修复前 map-snapshot 15/15、`infrastructure/geometry` 5272/5272 全不可渲染)。
+  - 因此新流程**不再产生 `UNVERIFIED`**;`UNVERIFIED_COORDINATE` 仅保留给历史快照与「调用方未提供校验段」的防御路径。
 - 成功但无数据返回 200(例如 `freshness.state = EMPTY`),不得用 5xx 表达空;仅上游读取失败才 `SERVER_ERROR`。
 - 时间范围口径:预设 `range` 取值 `LAST_24_HOURS` / `LAST_7_DAYS` / `LAST_30_DAYS`(`infrastructure/operating-information` 与 `overview/warning-settings` 同款);显式区间用 `startTime`/`endTime`(ISO-8601 带偏移,必须成对出现且 `startTime ≤ endTime`,回显 `range.type = CUSTOM`)。`overview/warning-settings` 不声明窗口时维持 `CURRENT_BASELINE`(`start`/`end` 为 `null`,趋势桶不筛选)。
 - `overview/warning-settings` 的趋势桶是**业务时区日期**(`yyyy-MM-dd`),声明窗口时按「桶日期落在窗口起止日期之间(含端点)」筛选,**桶顺序沿用适配器顺序**;桶标签无法解析时保留该桶,不静默丢数据。
 - 未知类型、非智能设备、非正式行业在**按行业/类型筛选时**显式排除,不做静默归类;`map-snapshot` 不带筛选时仍返回全部对象(`industry`/`typeName` 为 `null`),保证「全城底图」不因类型数据缺失而丢对象。
-- 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标缺失、越界或未验证时 `renderable = false` 且给出 `reason`。
-- 适配器未给出 `coordinateVerified` 时按**未验证**处理(`state=UNVERIFIED`、`reason=UNVERIFIED_COORDINATE`、`renderable=false`)——字典要求「未验证禁止渲染」,服务端不臆造验证状态。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
+- 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标结构非法、越界或落在主场景区外时 `renderable = false` 且给出 `reason`。
+- 事件坐标按同一自动准入判定(不再依赖 `coordinateVerified`):区内且数值合法 → `VALID` / `renderable=true`。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
 - `events/history-analysis` 的 `rows[]` 是**小时桶聚合行**,不存在逐事件行,因此不携带 `coordinateValidation` 与阈值字段(逐事件坐标只在 `events/current` 提供)。
 
 ### 监测点详情设备坐标
@@ -101,18 +105,18 @@
 | 字段 | 语义 |
 | --- | --- |
 | `longitude` / `latitude` | 来自 `equipment_base` 的设备坐标;不会复制所属监测点坐标,也不会人工偏移。缺失时字段为 `null`。 |
-| `coordinateValidation.state` | `VALID`、`INVALID` 或 `UNVERIFIED`。 |
-| `coordinateValidation.reason` | 不可上图时的非空原因,例如 `MISSING_LONGITUDE`、`LONGITUDE_OUT_OF_RANGE`、`ZERO_COORDINATE` 或 `UNVERIFIED_COORDINATE`。 |
-| `coordinateValidation.renderable` | 只有可信校核来源存在且坐标有效时为 `true`;前端仅在此为 `true` 时绘制设备点位。 |
+| `coordinateValidation.state` | `VALID`(可上图)或 `INVALID`(不可上图)。新流程不再产生 `UNVERIFIED`。 |
+| `coordinateValidation.reason` | 不可上图时的非空原因:`MISSING_LONGITUDE`、`MISSING_LATITUDE`、`LONGITUDE_OUT_OF_RANGE`、`LATITUDE_OUT_OF_RANGE`、`ZERO_COORDINATE` 或 `OUTSIDE_PRIMARY_AREA`。 |
+| `coordinateValidation.renderable` | 结构合法**且**落在主场景区内时为 `true`;前端仅在此为 `true` 时绘制设备点位。 |
 | `coordinateValidation.crs` | 当前统一为 `WGS84`。 |
 
-当前 `equipment_base` 没有可信坐标校核字段,因此真实适配器透传设备坐标但按 `UNVERIFIED_COORDINATE` 降级,`renderable=false`;设备仍保留在详情清单中。
+当前 `equipment_base` 没有可信坐标校核字段,但按 2026-10-12 裁定**不再需要**它:真实适配器透传设备坐标,由 `CockpitCoordinateValidator` 按「结构合法 + 主场景区内」自动准入,因此区内设备 `renderable=true` 可正常上图;区外设备保留在详情清单中并给出 `OUTSIDE_PRIMARY_AREA`。
 
 **跨端点同快照(#69)**:
 
 - 监测点详情接受可选查询参数 `snapshotAt`(ISO-8601、必须带 `+08:00`,语义与 `map-snapshot` 一致)。提供 `snapshotAt` 时,详情与地图按时间戳复用首次读取的地图快照;相同时间戳不会因两次请求之间的点位/设备关联变化而重新读取,详情 `devices[]` 与 `map-snapshot` 对应点位的 `deviceCodes` 集合一致(含离线设备、不重复、不静默丢失)。快照存于 Redis,保留至 `snapshotAt` 后 10 分钟的有效窗口结束,应用节点共享;单节点本地最多缓存 256 份以减少 Redis 读取。过期时返回 `SNAPSHOT_EXPIRED`;Redis/缓存不可用返回 `SERVER_ERROR`,调用方应分别刷新快照或提示上游暂不可用。
 - 未提供 `snapshotAt` 时走详情适配器路径(尽力模式):两个请求之间点位-设备关系变化时,详情与地图集合可能不一致,前端按决策票 05「跨端点尽力展示并提示可能不一致」处理。
-- **当前正式交付边界**:`equipment_base` 暂无可信坐标校核字段,现网关联设备均按 `UNVERIFIED_COORDINATE` 降级、`renderable=false`,前端准入规则下**只保留设备清单、不绘制设备位置**。可信坐标校核来源(如基于设备安装定位/人工复核的 `coordinateVerified` 字段)需另行排期,由后端在数据源就绪后接入并回填。
+- **坐标准入口径(2026-10-12 起)**:不再要求人工校核来源,改按「结构合法 + 落在主场景区内」自动判定。现网真实监测点/设备/管线坐标基本都落在主场景区内(实测 `map-snapshot` 12/15、`infrastructure/geometry` 5269/5272 可上图),因此**关联设备位置可正常绘制**。区外对象(含 `(0,0)` 零坐标、测试占位值)仍不可上图,但保留在清单与统计中并给出具体原因。
 
 ## 5. WebSocket 实时增量
 
@@ -205,7 +209,7 @@ mvn -pl zksy-admin -am test -Dtest='Cockpit*Test'
 ### 当前真实库只读观测
 
 - 既有只读探查记录:`work_order=1`、`alarm_data=3`、`early_warning=35`、`alarm_warning_rel=3`、`equipment_base=2`、`equipment_status=2`、`monitor_point=7`、`equipment_point_rel=20`、`case_info=2`、`hazard_hidden_account=12`;这些数量是观测时点事实,不代表生产规模。
-- 点位仍存在空坐标与 `(12,12)` 等异常坐标;当前 schema 没有可信坐标校核字段。缺可信校核来源一律 `coordinateVerified=false`、`renderable=false`;单纯经纬度范围校验不能替代业务校核。
+- 点位仍存在空坐标与 `(12,12)` 等异常坐标;当前 schema 没有可信坐标校核字段。2026-10-12 起不再依赖该字段:数值合法且落在主场景区内即 `renderable=true`(不再产生 `UNVERIFIED`),区外归 `OUTSIDE_PRIMARY_AREA`。**这仍是呈现层准入,不等于业务坐标已校核**——库里出现明显错误但落在区内的坐标依然会被画出来,人工核查该列数据的责任仍在业务侧。
 - 真实 `map-snapshot` HTTP 响应在本轮样例中返回 `total=15`,分页样例包含 9 个点位对象和 7 个设备编码;设备关系与样本数据会随数据库内容变化,响应的 `snapshotAt` 是唯一分页依据。
 
 ### 当前代码可验收性

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

@@ -70,9 +70,11 @@ class BusinessCockpitDeviceFactResolverTest {
         assertEquals("压力计", fact.getDeviceTypeName());
         assertEquals(new BigDecimal("110.4025"), fact.getLongitude());
         assertEquals(new BigDecimal("28.4500"), fact.getLatitude());
-        assertEquals("UNVERIFIED", fact.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", fact.getCoordinateValidation().getReason());
-        assertFalse(fact.getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:取消无入口的人工校核前置,改为「结构合法 + 落在主场景区内」自动准入。
+        // 本夹具坐标 110.4025/28.4500 落在主场景区内,故判定为可上图。
+        assertEquals("VALID", fact.getCoordinateValidation().getState());
+        assertNull(fact.getCoordinateValidation().getReason());
+        assertTrue(fact.getCoordinateValidation().isRenderable());
         assertEquals(CockpitMonitoringPointStatus.ALARM,
                 CockpitMonitoringPointStatusResolver.deviceStatus(fact));
     }

+ 7 - 5
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java

@@ -97,7 +97,8 @@ class BusinessCockpitMapSnapshotAdapterTest {
         assertEquals("Z-TEST-RQXL01", point.getDevices().get(0).getDeviceCode());
         assertEquals(new BigDecimal("110.4025"), point.getDevices().get(0).getLongitude());
         assertEquals(new BigDecimal("28.4500"), point.getDevices().get(0).getLatitude());
-        assertFalse(point.getDevices().get(0).getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:坐标落在主场景区内即自动准入,不再需要人工校核标记。
+        assertTrue(point.getDevices().get(0).getCoordinateValidation().isRenderable());
 
         assertTrue(snapshot.getTypes().stream().anyMatch(
                 type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())),
@@ -113,7 +114,7 @@ class BusinessCockpitMapSnapshotAdapterTest {
         EquipmentBase bound = device("D-1", "BOUND-1", "已关联", "11",
                 new BigDecimal("110.4"), new BigDecimal("28.4"));
         EquipmentBase free = device("D-2", "FREE-1", "未关联", "11",
-                new BigDecimal("110.5"), new BigDecimal("28.5"));
+                new BigDecimal("110.440"), new BigDecimal("28.450"));
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);
@@ -235,9 +236,10 @@ class BusinessCockpitMapSnapshotAdapterTest {
                 restoredSnapshot.getMonitoringPoints().get(0).getDevices().get(0);
         assertEquals("DEVICE-1", restoredDevice.getDeviceCode());
         assertEquals(new BigDecimal("110.4"), restoredDevice.getLongitude());
-        assertEquals("UNVERIFIED", restoredDevice.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", restoredDevice.getCoordinateValidation().getReason());
-        assertFalse(restoredDevice.getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:110.4/28.4 落在主场景区内,序列化往返后判定不变。
+        assertEquals("VALID", restoredDevice.getCoordinateValidation().getState());
+        assertNull(restoredDevice.getCoordinateValidation().getReason());
+        assertTrue(restoredDevice.getCoordinateValidation().isRenderable());
         assertFalse(restoredDevice.isOnline());
     }
 

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

@@ -63,7 +63,7 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
                 new BigDecimal("110.4024"), new BigDecimal("28.4499"), 1);
         EquipmentBase free = device("D-2", "FREE-1", "独立燃气计", "22",
-                new BigDecimal("110.5"), new BigDecimal("28.5"), null);
+                new BigDecimal("110.440"), new BigDecimal("28.450"), null);
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);

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

@@ -20,6 +20,7 @@ import java.util.Collections;
 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;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -66,9 +67,10 @@ class BusinessCockpitMonitoringPointDetailAdapterTest {
         assertFalse(detail.isCoordinateVerified(), "现网没有可信坐标校核字段");
         assertEquals(new BigDecimal("110.4025"), detail.getDevices().get(0).getLongitude());
         assertEquals(new BigDecimal("28.4500"), detail.getDevices().get(0).getLatitude());
-        assertEquals("UNVERIFIED", detail.getDevices().get(0).getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", detail.getDevices().get(0).getCoordinateValidation().getReason());
-        assertFalse(detail.getDevices().get(0).getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:坐标落在主场景区内即自动准入,不再需要人工校核标记。
+        assertEquals("VALID", detail.getDevices().get(0).getCoordinateValidation().getState());
+        assertNull(detail.getDevices().get(0).getCoordinateValidation().getReason());
+        assertTrue(detail.getDevices().get(0).getCoordinateValidation().isRenderable());
         assertEquals(1, detail.getDevices().size());
         assertEquals("DEV-001", detail.getDevices().get(0).getDeviceCode());
         assertEquals("压力计", detail.getDevices().get(0).getDeviceTypeName());

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

@@ -0,0 +1,161 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.math.BigDecimal;
+
+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;
+
+/**
+ * 坐标准入判据的单测。
+ *
+ * <p><b>2026-10-12 维护者裁定(取消人工校核前置)</b>:{@code coordinateVerified} 不再影响结果,
+ * 判据改为「结构合法(缺经纬度/越界/零坐标各有具体原因)+ 落在主场景区(闭区间)内」。
+ * 本测试钉住该判定的顺序、边界与「校核标记无效」这一核心变更。
+ */
+class CockpitCoordinateValidatorTest {
+
+    private static final BigDecimal WEST = new BigDecimal("110.328749941417");
+    private static final BigDecimal SOUTH = new BigDecimal("28.3917230659234");
+    private static final BigDecimal EAST = new BigDecimal("110.465779226663");
+    private static final BigDecimal NORTH = new BigDecimal("28.4913038992907");
+
+    /** 真实生产数据区间内的一个普通点。 */
+    private static final BigDecimal IN_AREA_LON = new BigDecimal("110.4024");
+    private static final BigDecimal IN_AREA_LAT = new BigDecimal("28.4499");
+
+    @Test
+    void inAreaCoordinatesAreValidAndRenderable() {
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, false);
+
+        assertEquals("VALID", validation.getState());
+        assertNull(validation.getReason(), "合法坐标不得携带原因");
+        assertTrue(validation.isRenderable());
+        assertEquals("WGS84", validation.getCrs());
+    }
+
+    /**
+     * 本次变更的核心:{@code coordinateVerified} 只是历史入参,不再作为准入门槛。
+     * 同一组区内坐标无论传 true 还是 false,结论必须相同。
+     */
+    @Test
+    void coordinateVerifiedNoLongerChangesTheOutcome() {
+        CockpitCoordinateValidation withFlag = CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, true);
+        CockpitCoordinateValidation withoutFlag = CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, false);
+
+        assertEquals(withFlag.getState(), withoutFlag.getState());
+        assertEquals("VALID", withoutFlag.getState());
+        assertEquals(withFlag.getReason(), withoutFlag.getReason());
+        assertNull(withoutFlag.getReason());
+        assertEquals(withFlag.isRenderable(), withoutFlag.isRenderable());
+        assertTrue(withoutFlag.isRenderable());
+    }
+
+    /** 东/西/南/北四个方向各来一例:区外一律 INVALID / OUTSIDE_PRIMARY_AREA / 不可上图。 */
+    @ParameterizedTest
+    @CsvSource({
+            "110.500000,28.4500000",
+            "110.300000,28.4500000",
+            "110.400000,28.5000000",
+            "110.400000,28.3000000"})
+    void outsidePrimaryAreaIsRejectedInEveryDirection(String longitude, String latitude) {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal(longitude), new BigDecimal(latitude), false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 区外坐标若同时带校核标记,仍然不可上图——标记不是通行证。 */
+    @Test
+    void outsidePrimaryAreaStaysRejectedEvenWhenMarkedVerified() {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal("110.1460"), new BigDecimal("28.4570"), true);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 主场景区是闭区间:正好落在四条边界上算命中。 */
+    @ParameterizedTest
+    @CsvSource({
+            "110.328749941417,28.3917230659234",
+            "110.465779226663,28.4913038992907",
+            "110.328749941417,28.4913038992907",
+            "110.465779226663,28.3917230659234"})
+    void boundaryCoordinatesCountAsInsideTheClosedRectangle(String longitude, String latitude) {
+        BigDecimal westOrEast = new BigDecimal(longitude);
+        BigDecimal southOrNorth = new BigDecimal(latitude);
+
+        assertTrue(CockpitCoordinateValidator.insidePrimaryArea(westOrEast, southOrNorth),
+                "闭区间边界必须算命中");
+
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(westOrEast, southOrNorth, false);
+        assertEquals("VALID", validation.getState());
+        assertNull(validation.getReason());
+        assertTrue(validation.isRenderable());
+    }
+
+    /** 边界外一丁点就不算命中,避免把闭区间写成开区间或写成越界。 */
+    @Test
+    void oneStepOutsideTheBoundaryIsNotInside() {
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(WEST.subtract(new BigDecimal("0.000001")), SOUTH));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(EAST.add(new BigDecimal("0.000001")), NORTH));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(WEST, SOUTH.subtract(new BigDecimal("0.000001"))));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(EAST, NORTH.add(new BigDecimal("0.000001"))));
+    }
+
+    /**
+     * 结构错误优先于范围判定:区外且缺经度必须报 MISSING_LONGITUDE,而不是被 OUTSIDE_PRIMARY_AREA 掩盖。
+     * 零坐标同理优先((0,0) 数值上也在区外,但必须如实报 ZERO_COORDINATE)。
+     */
+    @ParameterizedTest
+    @CsvSource({
+            "null,28.3000000,MISSING_LONGITUDE",
+            "110.300000,null,MISSING_LATITUDE",
+            "181,28.4500000,LONGITUDE_OUT_OF_RANGE",
+            "-181,28.4500000,LONGITUDE_OUT_OF_RANGE",
+            "110.400000,91,LATITUDE_OUT_OF_RANGE",
+            "110.400000,-91,LATITUDE_OUT_OF_RANGE",
+            "0,0,ZERO_COORDINATE"})
+    void structuralErrorsTakePrecedenceOverAreaCheck(String longitude, String latitude, String reason) {
+        BigDecimal longitudeValue = "null".equals(longitude) ? null : new BigDecimal(longitude);
+        BigDecimal latitudeValue = "null".equals(latitude) ? null : new BigDecimal(latitude);
+
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(longitudeValue, latitudeValue, false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals(reason, validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 数值合法但在区外的点,必须与结构错误区分开:原因只能是 OUTSIDE_PRIMARY_AREA。 */
+    @Test
+    void numericallyLegalButOutOfAreaHasItsOwnReason() {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal("-180"), new BigDecimal("90"), false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    @Test
+    void insidePrimaryAreaIsFalseWhenEitherCoordinateIsMissing() {
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(null, IN_AREA_LAT));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(IN_AREA_LON, null));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(null, null));
+    }
+}

+ 16 - 14
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java

@@ -107,9 +107,10 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[3].monitoringPointCode").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[4].eventNo").value("W-005"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].levelColor").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.state").value("UNVERIFIED"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.reason").value("UNVERIFIED_COORDINATE"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.renderable").value(false))
+                // 2026-10-12 契约:W-005 坐标 110.381/28.461 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.reason").value(nullValue()))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.sections.events.data[4].workOrderSummary.stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.reason").value("SUCCESS"))
@@ -133,7 +134,8 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMinValue").value(0.0))
                 .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMaxValue").value(20.0))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("A-002"))
-                .andExpect(jsonPath("$.data.sections.events.data[1].coordinateValidation.state").value("UNVERIFIED"))
+                // 2026-10-12 契约:A-002 坐标 110.400/28.480 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.sections.events.data[1].coordinateValidation.state").value("VALID"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].thresholdMinValue").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[1].workOrderSummary").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
@@ -346,15 +348,15 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
-                                "GAS_SENSOR", "110.130", "28.440", true),
+                                "GAS_SENSOR", "110.360", "28.440", true),
                         warning("W-002", "燃气压力预警", "GAS_PRESSURE", 2, "ACTIVE",
                                 "2026-09-02T09:45:00+08:00", "2026-09-02T09:46:00+08:00",
                                 "0.32", "MPa", "D-GAS-2", "燃气浓度计-002", "MP-A-002", "城区A-燃气2号点", "城区A-燃气2号点",
-                                "GAS_SENSOR", "110.131", "28.441"),
+                                "GAS_SENSOR", "110.361", "28.441"),
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
                                 "0.18", "MPa", "D-WS-1", "供水压力计-001", "MP-B-001", "城区B-供水1号点", "城区B-供水1号点",
-                                "WATER_SUPPLY_SENSOR", "110.150", "28.460"),
+                                "WATER_SUPPLY_SENSOR", "110.380", "28.460"),
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
                                 "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", null, null, "城区C-污水1号点",
@@ -362,24 +364,24 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-005", "供水流量预警", "WATER_FLOW", 9, "ACTIVE",
                                 "2026-09-02T09:30:00+08:00", "2026-09-02T09:31:00+08:00",
                                 "66.6", "L/s", "D-WS-2", "供水压力计-002", "MP-B-002", "城区B-供水2号点", "城区B-供水2号点",
-                                "WATER_SUPPLY_SENSOR", "110.151", "28.461"),
+                                "WATER_SUPPLY_SENSOR", "110.381", "28.461"),
                         warning("W-006", "未知类型预警", "LEGACY_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "1", "x", "D-LEGACY", "旧设备", null, null, "旧位置",
-                                "LEGACY", "110.100", "28.400"),
+                                "LEGACY", "110.340", "28.400"),
                         warning("W-007", "智能井盖预警", "MANHOLE_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "1", "x", "D-MH", "智能井盖", null, null, "井盖位置",
-                                "SMART_MANHOLE", "110.101", "28.401")),
+                                "SMART_MANHOLE", "110.341", "28.401")),
                 Arrays.asList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.132", "28.442", true, "0.00", "20.00"),
+                                "GAS_SENSOR", "110.362", "28.442", true, "0.00", "20.00"),
                         alarm("A-002", "污水浓度报警", "SEWAGE_CONCENTRATION", 2, "PROCESSING",
                                 "2026-09-02T09:32:00+08:00", "2026-09-02T09:33:00+08:00",
                                 "42.1", "mg/L", "D-DR-1", "污水环境监测仪-001", "MP-C-001", "城区C-污水1号点", "城区C-污水1号点",
-                                "SEWAGE_ENV", "110.170", "28.480")),
+                                "SEWAGE_ENV", "110.400", "28.480")),
                 Arrays.asList(
                         workOrder("W-001", "WO-1", 2, "燃气浓度处置", 3),
                         workOrder("W-003", "WO-2", 3, "供水压力处置", 1),
@@ -409,12 +411,12 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
-                                "GAS_SENSOR", "110.130", "28.440")),
+                                "GAS_SENSOR", "110.360", "28.440")),
                 Collections.singletonList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.132", "28.442")),
+                                "GAS_SENSOR", "110.362", "28.442")),
                 Collections.emptyList());
     }
 

+ 15 - 14
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryControllerTest.java

@@ -101,16 +101,16 @@ class CockpitFacilityGeometryControllerTest {
         com.zksy.base.domain.PipeNetworkBase line = new com.zksy.base.domain.PipeNetworkBase();
         line.setNetworkId("gas-line");
         line.setNetworkType("0");
-        line.setStartLongitude(new BigDecimal("110.4"));
-        line.setStartLatitude(new BigDecimal("28.4"));
-        line.setEndLongitude(new BigDecimal("110.6"));
-        line.setEndLatitude(new BigDecimal("28.6"));
+        line.setStartLongitude(new BigDecimal("110.360"));
+        line.setStartLatitude(new BigDecimal("28.420"));
+        line.setEndLongitude(new BigDecimal("110.420"));
+        line.setEndLatitude(new BigDecimal("28.460"));
         com.zksy.base.domain.GasPipePoint point = new com.zksy.base.domain.GasPipePoint();
         point.setPointId(1L);
         point.setPointType("三通");
         point.setNetworkId("gas-line");
-        point.setLongitude(new BigDecimal("110.5"));
-        point.setLatitude(new BigDecimal("28.5"));
+        point.setLongitude(new BigDecimal("110.440"));
+        point.setLatitude(new BigDecimal("28.450"));
         when(networks.list()).thenReturn(Collections.singletonList(line));
         when(points.list()).thenReturn(Collections.singletonList(point));
         when(manholes.list()).thenReturn(Collections.emptyList());
@@ -123,18 +123,19 @@ class CockpitFacilityGeometryControllerTest {
                 .andExpect(jsonPath("$.data.features", hasSize(2)))
                 .andExpect(jsonPath("$.data.features[0].geometry.type").value("LineString"))
                 .andExpect(jsonPath("$.data.features[0].geometry.coordinates", hasSize(2)))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][0]").value(110.4))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][1]").value(28.4))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][0]").value(110.6))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][1]").value(28.6))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][0]").value(110.360))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][1]").value(28.420))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][0]").value(110.420))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][1]").value(28.460))
                 .andExpect(jsonPath("$.data.features[1].geometry.type").value("Point"))
                 .andExpect(jsonPath("$.data.features[1].geometry.coordinates", hasSize(2)))
-                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[0]").value(110.5))
-                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[1]").value(28.5))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[0]").value(110.440))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[1]").value(28.450))
                 .andExpect(jsonPath("$.data.features[1].properties.pointType").value("三通"))
                 .andExpect(jsonPath("$.data.features[1].properties.networkId").value("gas-line"))
-                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.state").value("UNVERIFIED"))
-                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.renderable").value(false));
+                // 2026-10-12 契约:区内坐标自动准入 → VALID/可上图。
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.renderable").value(true));
     }
 
     @Test

+ 46 - 21
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryServiceTest.java

@@ -55,8 +55,8 @@ class CockpitFacilityGeometryServiceTest {
         when(points.list()).thenReturn(Collections.singletonList(point));
         ManholeBase manhole = new ManholeBase();
         manhole.setManholeId("same-id");
-        manhole.setLongitude(new BigDecimal("110.5"));
-        manhole.setLatitude(new BigDecimal("28.5"));
+        manhole.setLongitude(new BigDecimal("110.440"));
+        manhole.setLatitude(new BigDecimal("28.450"));
         when(manholes.list()).thenReturn(Collections.singletonList(manhole));
 
         CockpitFacilityGeometryResponse response = service.load();
@@ -68,8 +68,8 @@ class CockpitFacilityGeometryServiceTest {
                 response.getFeatures().stream().map(item -> item.getProperties().getIndustry()).collect(Collectors.toList()));
         Feature gas = response.getFeatures().get(0);
         assertEquals("LineString", gas.getGeometry().getType());
-        assertEquals(Arrays.asList(Arrays.asList(new BigDecimal("110.4"), new BigDecimal("28.4")),
-                Arrays.asList(new BigDecimal("110.6"), new BigDecimal("28.6"))), gas.getGeometry().getCoordinates());
+        assertEquals(Arrays.asList(Arrays.asList(new BigDecimal("110.360"), new BigDecimal("28.420")),
+                Arrays.asList(new BigDecimal("110.420"), new BigDecimal("28.460"))), gas.getGeometry().getCoordinates());
         assertEquals("GAS_PIPE", gas.getProperties().getTypeCode());
         assertEquals("PIPE_UNCLASSIFIED", response.getFeatures().get(3).getProperties().getTypeCode());
         assertEquals("3", response.getFeatures().get(3).getProperties().getNetworkType());
@@ -119,7 +119,7 @@ class CockpitFacilityGeometryServiceTest {
     }
 
     @Test
-    void invalidEndIsNotMaskedByAnUnverifiedStartAndEntireLineHasNullGeometry() {
+    void invalidEndIsNotMaskedByAValidStartAndEntireLineHasNullGeometry() {
         PipeNetworkBase line = network("bad-end", "1");
         line.setEndLatitude(null);
         when(networks.list()).thenReturn(Collections.singletonList(line));
@@ -127,14 +127,20 @@ class CockpitFacilityGeometryServiceTest {
         Feature feature = response.getFeatures().get(0);
         assertNull(feature.getGeometry());
         assertEquals("MISSING_LATITUDE", feature.getProperties().getCoordinateValidation().getReason());
-        assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidations().get(0).getState());
+        // 2026-10-12 契约:起点 110.360/28.420 落在主场景区内 → 单独判定为 VALID。
+        assertEquals("VALID", feature.getProperties().getCoordinateValidations().get(0).getState());
         assertEquals("INVALID", feature.getProperties().getCoordinateValidations().get(1).getState());
         assertEquals(1, coverage(response, "DRAINAGE", "LineString").getRecordCount());
         assertEquals(0, coverage(response, "DRAINAGE", "LineString").getGeometryCount());
     }
 
+    /**
+     * 2026-10-12 契约:数值落在 ±180/±90 内不再自动可渲染,判据改为「结构合法 + 落在主场景区内」。
+     * 本用例固化新语义:区内线段/构件可上图;数值合法但在区外的点被判 INVALID/OUTSIDE_PRIMARY_AREA,
+     * 且原始坐标原样保留、对象仍留在清单与统计中(只决定呈现,不决定是否计入)。
+     */
     @Test
-    void numericBoundsDoNotGrantCoordinateVerificationForAnySource() {
+    void outsidePrimaryAreaIsRejectedWhileInAreaCoordinatesRender() {
         when(networks.list()).thenReturn(Collections.singletonList(network("line", "0")));
         when(points.list()).thenReturn(Collections.singletonList(point(1L, "阀门")));
         ManholeBase manhole = new ManholeBase();
@@ -142,15 +148,34 @@ class CockpitFacilityGeometryServiceTest {
         manhole.setLongitude(new BigDecimal("-180"));
         manhole.setLatitude(new BigDecimal("90"));
         when(manholes.list()).thenReturn(Collections.singletonList(manhole));
+
         CockpitFacilityGeometryResponse response = service.load();
-        for (Feature feature : response.getFeatures()) {
-            assertNotNull(feature.getGeometry());
-            assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidation().getState());
-            assertEquals("UNVERIFIED_COORDINATE", feature.getProperties().getCoordinateValidation().getReason());
-            assertEquals("WGS84", feature.getProperties().getCoordinateValidation().getCrs());
-            assertFalse(feature.getProperties().getCoordinateValidation().isRenderable());
-        }
-        assertTrue(response.getCoverage().stream().allMatch(item -> item.getRenderableCount() == 0));
+
+        Feature line = response.getFeatures().get(0);
+        assertNotNull(line.getGeometry(), "区内两端点线段必须给出几何");
+        assertEquals("VALID", line.getProperties().getCoordinateValidation().getState());
+        assertNull(line.getProperties().getCoordinateValidation().getReason());
+        assertTrue(line.getProperties().getCoordinateValidation().isRenderable());
+
+        Feature component = response.getFeatures().get(1);
+        assertNotNull(component.getGeometry(), "区内构件点必须给出几何");
+        assertEquals("VALID", component.getProperties().getCoordinateValidation().getState());
+        assertTrue(component.getProperties().getCoordinateValidation().isRenderable());
+
+        Feature outside = response.getFeatures().get(2);
+        assertNull(outside.getGeometry());
+        assertEquals("INVALID", outside.getProperties().getCoordinateValidation().getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", outside.getProperties().getCoordinateValidation().getReason());
+        assertEquals("WGS84", outside.getProperties().getCoordinateValidation().getCrs());
+        assertFalse(outside.getProperties().getCoordinateValidation().isRenderable());
+        assertEquals(Arrays.asList(new BigDecimal("-180"), new BigDecimal("90")),
+                outside.getProperties().getSourceCoordinates().get(0), "区外坐标必须原样保留");
+
+        assertTrue(coverage(response, "GAS", "LineString").getRenderableCount() > 0,
+                "区内线段必须计入可渲染统计");
+        assertEquals(1, coverage(response, "GAS", "LineString").getRenderableCount());
+        assertEquals(1, coverage(response, "GAS", "Point").getRenderableCount());
+        assertEquals(0, coverage(response, "MANHOLE", "Point").getRenderableCount());
     }
 
     @Test
@@ -192,10 +217,10 @@ class CockpitFacilityGeometryServiceTest {
         network.setNetworkId(id);
         network.setNetworkCode("code-" + id);
         network.setNetworkType(type);
-        network.setStartLongitude(new BigDecimal("110.4"));
-        network.setStartLatitude(new BigDecimal("28.4"));
-        network.setEndLongitude(new BigDecimal("110.6"));
-        network.setEndLatitude(new BigDecimal("28.6"));
+        network.setStartLongitude(new BigDecimal("110.360"));
+        network.setStartLatitude(new BigDecimal("28.420"));
+        network.setEndLongitude(new BigDecimal("110.420"));
+        network.setEndLatitude(new BigDecimal("28.460"));
         return network;
     }
 
@@ -203,8 +228,8 @@ class CockpitFacilityGeometryServiceTest {
         GasPipePoint point = new GasPipePoint();
         point.setPointId(id);
         point.setPointType(type);
-        point.setLongitude(new BigDecimal("110.4"));
-        point.setLatitude(new BigDecimal("28.4"));
+        point.setLongitude(new BigDecimal("110.360"));
+        point.setLatitude(new BigDecimal("28.420"));
         return point;
     }
 

+ 11 - 9
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java

@@ -82,9 +82,11 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("LONGITUDE_OUT_OF_RANGE"))
                 .andExpect(jsonPath("$.data.coordinateValidation.crs").value("WGS84"))
                 .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
-                .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(3))
+                // 2026-10-12 契约:3 个点位 + EQ-FREE-002 + EQ-FREE-003 落在主场景区内 → 可上图共 5;
+                // EQ-FREE-001(181) 与 EQ-FREE-004(0,0) 仍为 INVALID,UNVERIFIED 不再产生。
+                .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(5))
                 .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
-                .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2))
+                .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].district").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].districtName").value("城南"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value("WATER_SUPPLY"))
@@ -434,7 +436,7 @@ class CockpitMapSnapshotControllerTest {
                                 "燃气调压站监测点",
                                 0,
                                 "GAS_SENSOR",
-                                new BigDecimal("110.142"),
+                                new BigDecimal("110.372"),
                                 new BigDecimal("28.453"),
                                 true,
                                 Collections.singletonList(device(
@@ -444,7 +446,7 @@ class CockpitMapSnapshotControllerTest {
                                 "供水干管监测点",
                                 0,
                                 "WATER_SUPPLY_SENSOR",
-                                new BigDecimal("110.141"),
+                                new BigDecimal("110.371"),
                                 new BigDecimal("28.452"),
                                 true,
                                 Collections.emptyList()),
@@ -453,7 +455,7 @@ class CockpitMapSnapshotControllerTest {
                                 "污水排口监测点",
                                 null,
                                 "SEWAGE_ENV",
-                                new BigDecimal("110.143"),
+                                new BigDecimal("110.373"),
                                 new BigDecimal("28.454"),
                                 false,
                                 Collections.emptyList())),
@@ -463,7 +465,7 @@ class CockpitMapSnapshotControllerTest {
                                 "燃气浓度计-001",
                                 0,
                                 "GAS_SENSOR",
-                                new BigDecimal("110.142"),
+                                new BigDecimal("110.372"),
                                 new BigDecimal("28.453"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -471,7 +473,7 @@ class CockpitMapSnapshotControllerTest {
                                 "智能井盖-002",
                                 null,
                                 null,
-                                new BigDecimal("110.145"),
+                                new BigDecimal("110.375"),
                                 new BigDecimal("28.456"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -487,7 +489,7 @@ class CockpitMapSnapshotControllerTest {
                                 "污水环境监测仪-003",
                                 2,
                                 "SEWAGE_ENV",
-                                new BigDecimal("110.146"),
+                                new BigDecimal("110.376"),
                                 new BigDecimal("28.457"),
                                 false),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -524,7 +526,7 @@ class CockpitMapSnapshotControllerTest {
                 pointCode + "监测点",
                 district,
                 typeCode,
-                new BigDecimal("110.141"),
+                new BigDecimal("110.371"),
                 new BigDecimal("28.452"),
                 true,
                 devices);

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

@@ -382,8 +382,12 @@ class CockpitMonitoringDistributionControllerTest {
         assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
     }
 
+    /**
+     * 2026-10-12 契约:坐标能否上图只决定呈现,不决定是否计入台账与统计。
+     * 这六个无类型空点坐标落在主场景区内,故各自可上图;方法名据实去掉「WithoutVerifiedCoordinates」。
+     */
     @Test
-    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
+    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotals() throws Exception {
         List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
         for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
             points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
@@ -405,7 +409,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
+                // 2026-10-12 契约:110.399876/28.458287 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
                 .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
                 .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))

+ 11 - 9
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailControllerTest.java

@@ -23,6 +23,7 @@ import java.util.Collections;
 import java.util.List;
 
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -85,11 +86,12 @@ class CockpitMonitoringPointDetailControllerTest {
                 .andExpect(jsonPath("$.data.deviceStatistics.offlineDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.devices", hasSize(5)))
                 .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
-                .andExpect(jsonPath("$.data.devices[0].longitude").value(110.142))
+                .andExpect(jsonPath("$.data.devices[0].longitude").value(110.372))
                  .andExpect(jsonPath("$.data.devices[0].latitude").value(28.453))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.state").value("UNVERIFIED"))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.reason").value("UNVERIFIED_COORDINATE"))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.renderable").value(false))
+                 // 2026-10-12 契约:110.372/28.453 落在主场景区内 → 自动准入。
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.state").value("VALID"))
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.reason").value(nullValue()))
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.renderable").value(true))
                  .andExpect(jsonPath("$.data.devices[0].deviceStatus").value("ALARM"))
                 .andExpect(jsonPath("$.data.devices[0].statusLabel").value("报警"))
                 .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
@@ -202,7 +204,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T10:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
                                 device("EQ-001", true, false, false, true, Collections.emptyList())))),
                 Collections.emptyList());
@@ -238,7 +240,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T11:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.emptyList())),
                 Collections.emptyList());
         when(mapAdapter.load("2026-09-02T10:00:00+08:00")).thenReturn(snapshot);
@@ -295,7 +297,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T10:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
                                 device(" EQ-001 ", true, false, false, true, Collections.emptyList())))),
                 Collections.emptyList());
@@ -355,7 +357,7 @@ class CockpitMonitoringPointDetailControllerTest {
         return new CockpitMonitoringPointDetail(
                 pointCode,
                 "供水干管监测点",
-                new BigDecimal("110.141"),
+                new BigDecimal("110.371"),
                 new BigDecimal("28.452"),
                 true,
                 devices);
@@ -367,7 +369,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 deviceCode,
                 deviceCode + "设备",
                 "压力计",
-                new BigDecimal("110.142"),
+                new BigDecimal("110.372"),
                 new BigDecimal("28.453"),
                 false,
                 online,

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

@@ -63,12 +63,12 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-003", "110.142", "28.453", false, true, false, false, true),
-                                device("EQ-001", "110.143", "28.454", false, true, true, false, true),
-                                device("EQ-005", "110.144", "28.455", false, false, false, true, true),
-                                device("EQ-002", "110.145", "28.456", false, true, false, false, false),
+                                device("EQ-003", "110.372", "28.453", false, true, false, false, true),
+                                device("EQ-001", "110.373", "28.454", false, true, true, false, true),
+                                device("EQ-005", "110.374", "28.455", false, false, false, true, true),
+                                device("EQ-002", "110.375", "28.456", false, true, false, false, false),
                                 device("EQ-004", null, null, false, false, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -102,12 +102,13 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-VALID", "110.142", "28.453", false, true, true, false, false),
+                                device("EQ-VALID", "110.372", "28.453", false, true, true, false, false),
                                 device("EQ-MISSING", null, null, false, false, false, false, false),
                                 device("EQ-INVALID", "200.000", "28.453", true, true, false, false, false),
-                                device("EQ-UNVERIFIED", "110.146", "28.457", false, true, false, false, false))));
+                                // 110.146 刻意留在主场景区外,用于固化 OUTSIDE_PRIMARY_AREA 这一新原因
+                                device("EQ-OUTSIDE", "110.146", "28.457", false, true, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
         CockpitMonitoringPointDetailResponse response =
@@ -116,27 +117,31 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         List<CockpitMonitoringPointDeviceCard> devices = response.getDevices();
         assertEquals(4, devices.size());
 
+        // 2026-10-12 契约:区内坐标自动准入 → VALID / reason=null / 可上图。
         CockpitMonitoringPointDeviceCard valid = byCode(devices, "EQ-VALID");
-        assertEquals(new BigDecimal("110.142"), valid.getLongitude());
+        assertEquals(new BigDecimal("110.372"), valid.getLongitude());
         assertEquals(new BigDecimal("28.453"), valid.getLatitude());
-        assertEquals("UNVERIFIED", valid.getCoordinateValidation().getState());
-        assertFalse(valid.getCoordinateValidation().isRenderable());
+        assertEquals("VALID", valid.getCoordinateValidation().getState());
+        assertNull(valid.getCoordinateValidation().getReason());
+        assertTrue(valid.getCoordinateValidation().isRenderable());
 
         CockpitMonitoringPointDeviceCard missing = byCode(devices, "EQ-MISSING");
         assertNull(missing.getLongitude());
         assertNull(missing.getLatitude());
         assertFalse(missing.getCoordinateValidation().isRenderable());
-        assertEquals("UNVERIFIED_COORDINATE", missing.getCoordinateValidation().getReason());
+        assertEquals("MISSING_LONGITUDE", missing.getCoordinateValidation().getReason());
 
         CockpitMonitoringPointDeviceCard invalid = byCode(devices, "EQ-INVALID");
         assertEquals("INVALID", invalid.getCoordinateValidation().getState());
         assertEquals("LONGITUDE_OUT_OF_RANGE", invalid.getCoordinateValidation().getReason());
         assertFalse(invalid.getCoordinateValidation().isRenderable());
 
-        CockpitMonitoringPointDeviceCard unverified = byCode(devices, "EQ-UNVERIFIED");
-        assertEquals("UNVERIFIED", unverified.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", unverified.getCoordinateValidation().getReason());
-        assertFalse(unverified.getCoordinateValidation().isRenderable());
+        CockpitMonitoringPointDeviceCard outside = byCode(devices, "EQ-OUTSIDE");
+        assertEquals(new BigDecimal("110.146"), outside.getLongitude(), "区外原始坐标必须原样保留");
+        assertEquals(new BigDecimal("28.457"), outside.getLatitude());
+        assertEquals("INVALID", outside.getCoordinateValidation().getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", outside.getCoordinateValidation().getReason());
+        assertFalse(outside.getCoordinateValidation().isRenderable());
     }
 
     @Test
@@ -144,7 +149,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith("2026-09-02T11:00:00+08:00",
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.emptyList()));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -157,7 +162,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-OTHER", "另一个点位", null, "11",
-                        new BigDecimal("110.1"), new BigDecimal("28.4"), false,
+                        new BigDecimal("110.340"), new BigDecimal("28.4"), false,
                         Collections.emptyList()));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -171,8 +176,8 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         when(detailAdapter.load("MP-001"))
                 .thenReturn(new com.zksy.cockpit.point.CockpitMonitoringPointDetail(
                         "MP-001", "供水干管监测点",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
-                        Collections.singletonList(device("EQ-001", "110.142", "28.453",
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
+                        Collections.singletonList(device("EQ-001", "110.372", "28.453",
                                 false, true, false, false, false))));
 
         CockpitMonitoringPointDetailResponse response =
@@ -187,8 +192,8 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         when(detailAdapter.load("MP-001"))
                 .thenReturn(new com.zksy.cockpit.point.CockpitMonitoringPointDetail(
                         "MP-001", "供水干管监测点",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
-                        Collections.singletonList(device("EQ-001", "110.142", "28.453",
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
+                        Collections.singletonList(device("EQ-001", "110.372", "28.453",
                                 false, true, false, false, false))));
 
         CockpitMonitoringPointDetailResponse response =
@@ -210,12 +215,12 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-001", "110.142", "28.453", false, true, false, false, false),
-                                device(" EQ-001 ", "110.143", "28.454", false, true, false, false, false),
-                                device(null, "110.144", "28.455", false, false, false, false, false),
-                                device("", "110.145", "28.456", false, false, false, false, false),
+                                device("EQ-001", "110.372", "28.453", false, true, false, false, false),
+                                device(" EQ-001 ", "110.373", "28.454", false, true, false, false, false),
+                                device(null, "110.374", "28.455", false, false, false, false, false),
+                                device("", "110.375", "28.456", false, false, false, false, false),
                                 device("EQ-002", null, null, false, false, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -244,9 +249,9 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
-                                device("EQ-VERIFIED", "110.142", "28.453",
+                                device("EQ-VERIFIED", "110.372", "28.453",
                                         true, true, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -254,7 +259,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
                 detailService.load("req", "MP-001", SNAPSHOT_AT);
 
         CockpitMonitoringPointDeviceCard verified = byCode(response.getDevices(), "EQ-VERIFIED");
-        assertEquals(new BigDecimal("110.142"), verified.getLongitude());
+        assertEquals(new BigDecimal("110.372"), verified.getLongitude());
         assertEquals(new BigDecimal("28.453"), verified.getLatitude());
         assertEquals("VALID", verified.getCoordinateValidation().getState());
         assertTrue(verified.getCoordinateValidation().isRenderable());

+ 11 - 10
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java

@@ -407,27 +407,28 @@ class CockpitMonitoringSituationControllerTest {
                         type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                         device("D-GAS-1", "燃气浓度计-001", 0, "GAS_SENSOR",
-                                "NORMAL", Collections.emptyList(), "110.130", "28.440", true),
+                                "NORMAL", Collections.emptyList(), "110.360", "28.440", true),
                         device("D-GAS-2", "燃气浓度计-002", 0, "GAS_SENSOR",
-                                "WARNING", Collections.singletonList("流量波动"), "110.131", "28.441", true),
+                                "WARNING", Collections.singletonList("流量波动"), "110.361", "28.441", true),
                         device("D-GAS-3", "燃气浓度计-003", 0, "GAS_SENSOR",
-                                "ALARM", Collections.singletonList("浓度超限"), "110.132", "28.442", true),
+                                "ALARM", Collections.singletonList("浓度超限"), "110.362", "28.442", true),
                         device("D-WS-1", "供水压力计-001", 1, "WATER_SUPPLY_SENSOR",
-                                "EXPIRED", Collections.singletonList("采样过期"), "110.150", "28.460", true),
+                                "EXPIRED", Collections.singletonList("采样过期"), "110.380", "28.460", true),
                         // 台账未登记片区 → 未分区(null),如实呈现而非猜一个片区
                         device("D-WS-2", "供水压力计-002", null, "WATER_SUPPLY_SENSOR",
-                                "OFFLINE", Collections.singletonList("离线"), "110.151", "28.461", true),
+                                "OFFLINE", Collections.singletonList("离线"), "110.381", "28.461", true),
                         // 无片区来源 → 未分区(null),如实呈现而非猜一个片区
                         device("D-DR-1", "污水环境监测仪-001", null, "SEWAGE_ENV",
-                                "NORMAL", Collections.emptyList(), "110.170", "28.480", true),
+                                "NORMAL", Collections.emptyList(), "110.400", "28.480", true),
                         device("D-DR-2", "污水环境监测仪-002", null, "SEWAGE_ENV",
-                                "UNKNOWN", Collections.singletonList("状态缺失"), "110.171", "28.481", true),
+                                "UNKNOWN", Collections.singletonList("状态缺失"), "110.401", "28.481", true),
                         device("D-MISMATCH", "水厂设备", 0, "WATER_PLANT",
-                                "NORMAL", Collections.emptyList(), "110.120", "28.430", true),
+                                "NORMAL", Collections.emptyList(), "110.350", "28.430", true),
                         device("D-UNKNOWN", "旧设备", 0, "LEGACY",
-                                "NORMAL", Collections.emptyList(), "110.121", "28.431", true),
+                                "NORMAL", Collections.emptyList(), "110.351", "28.431", true),
                         device("D-MH", "智能井盖", 2, "SMART_MANHOLE",
-                                "NORMAL", Collections.emptyList(), "110.122", "28.432", true),
+                                "NORMAL", Collections.emptyList(), "110.352", "28.432", true),
+                        // 越界经度保持原样:它要测的就是 LONGITUDE_OUT_OF_RANGE 导致不可上图
                         device("D-EXCL-GAS", "越界坐标燃气计", 0, "GAS_SENSOR",
                                 "ALARM", Collections.singletonList("浓度超限"), "181", "28.443", true)),
                 Arrays.asList(

+ 40 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java

@@ -2,17 +2,42 @@ package com.zksy.cockpit.foundation;
 
 import java.math.BigDecimal;
 
+/**
+ * 坐标准入判据(唯一一处)。
+ *
+ * <p><b>2026-10-12 维护者裁定(取消人工校核前置)</b>:原先第一道判断是
+ * {@code if (!coordinateVerified) → UNVERIFIED/不可渲染},而现网既没有可信校核字段,
+ * 也没有任何校核入口,导致全部真实点位/设备/几何恒为不可上图(实测 map-snapshot 15/15、
+ * geometry 5272/5272 全不可渲染)。维护者判定该步骤没有必要:**进入数据库的坐标即视为可信来源**,
+ * 改为按「结构合法 + 落在主场景区范围内」自动准入。
+ *
+ * <p>判定顺序刻意是「结构错误优先」:缺经纬度、越界、零坐标各有具体原因,
+ * 不能被范围判定掩盖;只有结构合法时才继续判是否落在主场景区内。
+ *
+ * <p>{@code coordinateVerified} 入参保留:它仍写入快照/Redis 序列化形状与既有调用点,
+ * 但**不再作为准入依据**(现网所有适配器都传 {@code false},若继续当门槛就等于恒不可上图)。
+ * 参数名与调用点保持原样是为了让本改动只落在判据一处,便于复核。
+ */
 public final class CockpitCoordinateValidator {
     private static final String WGS84 = "WGS84";
 
+    /**
+     * 主场景区矩形 {@code [西, 南, 东, 北]}(度)。
+     *
+     * <p>取值与部署配置 {@code map.primaryArea.bounds} 一致(甲方框定的沅陵县城范围)。
+     * 该范围只作**呈现准入**:区外坐标保留在清单与详情中并给出明确原因,
+     * 不静默丢弃、不改写坐标(CONTEXT.md「无地图位置」)。
+     */
+    public static final BigDecimal WEST = new BigDecimal("110.328749941417");
+    public static final BigDecimal SOUTH = new BigDecimal("28.3917230659234");
+    public static final BigDecimal EAST = new BigDecimal("110.465779226663");
+    public static final BigDecimal NORTH = new BigDecimal("28.4913038992907");
+
     private CockpitCoordinateValidator() {
     }
 
     public static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude,
             boolean coordinateVerified) {
-        if (!coordinateVerified) {
-            return new CockpitCoordinateValidation("UNVERIFIED", "UNVERIFIED_COORDINATE", false, WGS84);
-        }
         if (longitude == null) {
             return new CockpitCoordinateValidation("INVALID", "MISSING_LONGITUDE", false, WGS84);
         }
@@ -28,6 +53,18 @@ public final class CockpitCoordinateValidator {
         if (longitude.signum() == 0 && latitude.signum() == 0) {
             return new CockpitCoordinateValidation("INVALID", "ZERO_COORDINATE", false, WGS84);
         }
+        if (!insidePrimaryArea(longitude, latitude)) {
+            return new CockpitCoordinateValidation("INVALID", "OUTSIDE_PRIMARY_AREA", false, WGS84);
+        }
         return new CockpitCoordinateValidation("VALID", null, true, WGS84);
     }
+
+    /** 是否落在主场景区内(闭区间;边界值算命中)。 */
+    public static boolean insidePrimaryArea(BigDecimal longitude, BigDecimal latitude) {
+        if (longitude == null || latitude == null) {
+            return false;
+        }
+        return longitude.compareTo(WEST) >= 0 && longitude.compareTo(EAST) <= 0
+                && latitude.compareTo(SOUTH) >= 0 && latitude.compareTo(NORTH) <= 0;
+    }
 }