From b6fa4a27737dd9e60448953fddd561b4aff94f68 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 10:13:01 +0500 Subject: [PATCH] =?UTF-8?q?fix(parcels):=20guard=20NULL=20geom=20+=20None?= =?UTF-8?q?=20lat/lon=20=D0=B2=20isochrones/poi-score=20(#1201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cad_parcels_geom` — VIEW над `cad_parcels` где `geom` nullable (~964 prod- строк с NULL geom задокументировано в analyze:1504-1507). Без фильтра `geom IS NOT NULL` в UNION-ветках: - NULL-строка из ранней ветки (`cad_quarters_geom`) ПЕРЕКРЫВАЕТ валидную геометрию из поздней (`cad_parcels_geom`) — LIMIT 1 берёт первую попавшуюся. - ST_Centroid(NULL) → NULL → coord_row.lat = NULL → float(None) → TypeError → 500 (вместо честного 404). Guard `if not coord_row` НЕ ловил эту дыру: RowMapping с None-полями truthy. Зеркало паттерна из analyze (parcels.py:1418-1438) для обоих эндпоинтов (`/isochrones` + `/poi-score`): 1. `AND geom IS NOT NULL` во ВСЕХ ветках UNION. 2. Belt-and-suspenders: явная проверка `coord_row.get("lat") is None` на случай топологически битого polygon'а (NOT-NULL geom, NULL centroid). 26 analyze/isochrones/poi-score юнит-тестов зелёные. ruff clean. Closes #1201 --- backend/app/api/v1/parcels.py | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index bd423b5e..58f27692 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2933,18 +2933,25 @@ def get_isochrones( detail=f"times_min значения вне диапазона 1-60: {invalid_times}", ) - # Получить координаты центроида из доступных геометрий участка + # Получить координаты центроида из доступных геометрий участка. + # `geom IS NOT NULL` во ВСЕХ ветках UNION (#1201): cad_parcels_geom — VIEW + # над cad_parcels с nullable geom, 964 prod-строки. Без фильтра NULL-строка + # ранней ветки скрывает валидную из поздней + ST_Centroid(NULL) → NULL → 500. + # Зеркало паттерна analyze (parcels.py:1418-1438). coord_row = ( db.execute( text(""" SELECT ST_X(ST_Centroid(g.geom)) AS lon, ST_Y(ST_Centroid(g.geom)) AS lat FROM ( - SELECT geom FROM cad_quarters_geom WHERE cad_number = :c + SELECT geom FROM cad_quarters_geom + WHERE cad_number = :c AND geom IS NOT NULL UNION ALL - SELECT geom FROM cad_buildings WHERE cad_num = :c + SELECT geom FROM cad_buildings + WHERE cad_num = :c AND geom IS NOT NULL UNION ALL - SELECT geom FROM cad_parcels_geom WHERE cad_num = :c + SELECT geom FROM cad_parcels_geom + WHERE cad_num = :c AND geom IS NOT NULL ) g LIMIT 1 """), @@ -2954,7 +2961,11 @@ def get_isochrones( .first() ) - if not coord_row: + # belt-and-suspenders: row может вернуться с NULL lat/lon только если в БД + # есть строка с НЕ-NULL geom но битый ST_Centroid (топологически невалидный + # polygon). Граница `not coord_row` truthy-проверка на RowMapping её бы НЕ + # поймала (RowMapping с None-полями всё ещё truthy) → float(None) → 500. + if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None: raise HTTPException( status_code=404, detail=f"Геометрия для {cad_num} не найдена.", @@ -3029,18 +3040,24 @@ async def get_poi_score( POI берутся из osm_poi_ekb в заданном радиусе (default 2000м). Отсортированы по weight DESC — наиболее значимые объекты первыми. """ - # Получить координаты центроида участка из геометрических таблиц + # Получить координаты центроида участка из геометрических таблиц. + # См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках + # UNION (#1201): NULL-строка ранней ветки скрывает валидную из поздней + + # ST_Centroid(NULL) → NULL → float(None) → 500. Зеркало analyze (parcels.py:1418-1438). coord_row = ( db.execute( text(""" SELECT ST_X(ST_Centroid(g.geom)) AS lon, ST_Y(ST_Centroid(g.geom)) AS lat FROM ( - SELECT geom FROM cad_quarters_geom WHERE cad_number = :c + SELECT geom FROM cad_quarters_geom + WHERE cad_number = :c AND geom IS NOT NULL UNION ALL - SELECT geom FROM cad_buildings WHERE cad_num = :c + SELECT geom FROM cad_buildings + WHERE cad_num = :c AND geom IS NOT NULL UNION ALL - SELECT geom FROM cad_parcels_geom WHERE cad_num = :c + SELECT geom FROM cad_parcels_geom + WHERE cad_num = :c AND geom IS NOT NULL ) g LIMIT 1 """), @@ -3050,7 +3067,8 @@ async def get_poi_score( .first() ) - if not coord_row: + # belt-and-suspenders: см. get_isochrones — RowMapping с None-полями truthy. + if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None: raise HTTPException( status_code=404, detail=f"Геометрия для {cad_num} не найдена.",