gendesign/backend/app/services/site_finder/okn_lookup.py
Light1YT c3c6e2cef1 fix(okn): expand registry coverage + load detail-card name/address (#1159)
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
2026-06-13 15:02:50 +05:00

102 lines
4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ОКН-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"]