fix(parcels): guard NULL geom + None lat/lon в isochrones/poi-score (#1201)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m27s

`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
This commit is contained in:
Light1YT 2026-06-13 10:13:01 +05:00
parent f12c03588d
commit b6fa4a2773

View file

@ -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} не найдена.",