okn_objects_sync собирал 428 ОКН из ~798 ЕГРОКН и сохранял raw_props={id}
без названия/адреса — analyze показывал 'ОКН рядом' без указания КАКОГО.
Coverage: расширил _ADDRESS_PASSES с 2 до 11 подстрок (Екатеринбург,
г. Екатеринбург, 7 районов ЕКБ, 2 формы Свердловской обл.). _PASSES =
декартово (address × category). first-wins дедуп сохраняет высшую категорию.
Detail: fetch_okn_detail(source_id) — GET Show/Show?id=, BeautifulSoup
парсит HTML (dl/dt/dd и tr/td), извлекает name, address, protection_category,
object_kind, dating. _enrich_with_detail сливает в raw_props с graceful
failover (cap=1500, exception per-object не валит прогон).
Lookup: parcel_okn_objects возвращает name/address из raw_props->>'name'.
Existing ird_analyze тесты не сломаны.
49 okn-related тестов passed, ruff clean.
Closes #1159
102 lines
4 KiB
Python
102 lines
4 KiB
Python
"""ОКН-lookup для analyze_parcel (#1066, #1159).
|
||
|
||
Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия
|
||
в заданном радиусе от участка — [{source_id, category, distance_m, name,
|
||
address}].
|
||
|
||
ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс
|
||
idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326.
|
||
|
||
``name``/``address`` извлекаются из ``raw_props`` (обогащаются воркером через
|
||
detail-карточку ЕГРКН). Если detail недоступен — возвращается None для этих
|
||
полей, остальные поля сохраняются. Без detail-фикса (#1159 acceptance 2)
|
||
analyze-таблица показывала «ОКН рядом» без указания КАКОГО — критично для ИРД.
|
||
|
||
Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration)
|
||
или БД-ошибка — возвращает пустой список (analyze не падает).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Пространственный запрос: ST_DWithin по geography даёт расстояние в метрах.
|
||
# CAST psycopg v3 (никогда :param::type — vault Pattern_CAST_AS_Type).
|
||
# raw_props->>'name'/'address' — JSON-string accessor (NULL если ключа нет
|
||
# или raw_props=NULL). Безопасно для строк без detail-обогащения.
|
||
_OKN_DWITHIN_SQL = text("""
|
||
SELECT
|
||
source_id,
|
||
category,
|
||
ROUND(
|
||
ST_Distance(
|
||
geom::geography,
|
||
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography
|
||
)
|
||
)::integer AS distance_m,
|
||
raw_props->>'name' AS name,
|
||
raw_props->>'address' AS address
|
||
FROM okn_objects
|
||
WHERE ST_DWithin(
|
||
geom::geography,
|
||
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)::geography,
|
||
CAST(:radius_m AS double precision)
|
||
)
|
||
ORDER BY distance_m ASC
|
||
""")
|
||
|
||
|
||
def parcel_okn_objects(
|
||
db: Session,
|
||
parcel_wkt: str | None,
|
||
radius_m: float = 100.0,
|
||
) -> list[dict[str, Any]]:
|
||
"""ОКН-объекты в радиусе radius_m от участка.
|
||
|
||
Args:
|
||
db: SQLAlchemy-сессия.
|
||
parcel_wkt: геометрия участка в WKT (EPSG:4326). None → пустой список.
|
||
radius_m: радиус поиска в метрах (по умолчанию 100 м).
|
||
|
||
Returns:
|
||
Список dict [{source_id, category, distance_m, name, address}],
|
||
отсортированный по расстоянию ASC. ``name``/``address`` могут быть
|
||
None если detail-карточка не догружена. Пустой список при
|
||
отсутствии участка / таблицы / объектов в радиусе (analyze не падает).
|
||
"""
|
||
if not parcel_wkt:
|
||
return []
|
||
try:
|
||
rows = db.execute(
|
||
_OKN_DWITHIN_SQL,
|
||
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
|
||
).fetchall()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc)
|
||
return []
|
||
except Exception as exc:
|
||
logger.warning("parcel_okn_objects: ошибка запроса okn_objects: %s", exc)
|
||
return []
|
||
|
||
result: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
result.append(
|
||
{
|
||
"source_id": row[0],
|
||
"category": row[1],
|
||
"distance_m": row[2],
|
||
"name": row[3],
|
||
"address": row[4],
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
__all__ = ["parcel_okn_objects"]
|