diff --git a/backend/app/services/scrapers/okn_egrkn_client.py b/backend/app/services/scrapers/okn_egrkn_client.py new file mode 100644 index 00000000..c61f36a8 --- /dev/null +++ b/backend/app/services/scrapers/okn_egrkn_client.py @@ -0,0 +1,128 @@ +"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте (#1066). + +Источник: https://okn-mk.mkrf.ru/Maps/searchMap +Метод: POST, Content-Type: application/x-www-form-urlencoded +Аутентификация: не требуется (открытый endpoint). + +Ответ: GeoJSON FeatureCollection. +Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {...}}. + +ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat]. +Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) → (lon, lat). + +TLS: mkrf.ru использует сертификат, верификация по умолчанию включена. +При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242). +""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +_SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap" + +# Таймаут на один HTTP-запрос к ЕГРКН (секунды) +_REQUEST_TIMEOUT = 30.0 + + +def _build_form_data(address: str, category_type: int) -> list[tuple[str, str]]: + """Построить form-data в формате jQuery serializeArray для поиска по карте. + + Формат: повторяющиеся пары data[i][name] / data[i][value]. + httpx сохраняет порядок и дублирующиеся имена при передаче списком кортежей. + """ + return [ + ("data[0][name]", "CultureObjects[cob_address]"), + ("data[0][value]", address), + ("data[1][name]", "CultureObjects[cob_category_type]"), + ("data[1][value]", str(category_type)), + ] + + +def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]: + """Запросить список ОКН-объектов из ЕГРКН по адресу и категории. + + Args: + address: строка поиска (например «Екатеринбург» или «Свердловская»). + category_type: 1 — федеральный, 2 — региональный, 4 — местного значения. + + Returns: + Список feature-dict'ов из GeoJSON FeatureCollection. + Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]), + properties (сырые свойства из ЕГРКН, часть полей требует авторизации). + Пустой список при ошибке или пустом ответе. + + Note: + ЕГРКН возвращает coordinates в порядке [lat, lon] (Яндекс-конвенция). + Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0]. + """ + form_data = _build_form_data(address, category_type) + features = _do_request(form_data, verify=True) + return features + + +def _is_ssl_error(exc: Exception) -> bool: + """Проверить, является ли исключение SSL-ошибкой. + + httpx >= 0.28 не экспортирует отдельный SSLError — SSL-ошибки приходят + как httpx.ConnectError с SSL-сообщением в тексте или через цепочку __cause__. + """ + msg = str(exc).lower() + ssl_keywords = ("ssl", "certificate", "cert verify", "tls", "handshake") + if any(kw in msg for kw in ssl_keywords): + return True + cause = getattr(exc, "__cause__", None) or getattr(exc, "__context__", None) + if cause is not None: + return any(kw in str(cause).lower() for kw in ssl_keywords) + return False + + +def _do_request( + form_data: list[tuple[str, str]], + *, + verify: bool, +) -> list[dict[str, Any]]: + """Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.""" + try: + with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify) as client: + resp = client.post( + _SEARCHMAP_URL, + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc) + return [] + except httpx.ConnectError as exc: + if verify and _is_ssl_error(exc): + logger.warning( + "okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc + ) + return _do_request(form_data, verify=False) + logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc) + return [] + except Exception as exc: + logger.error("okn_egrkn_client: ошибка запроса к ЕГРКН: %s", exc) + return [] + + try: + payload: dict[str, Any] = resp.json() + except Exception as exc: + logger.error("okn_egrkn_client: не удалось распарсить JSON-ответ ЕГРКН: %s", exc) + return [] + + features: list[dict[str, Any]] = payload.get("features") or [] + if not isinstance(features, list): + logger.warning("okn_egrkn_client: неожиданный тип features: %s", type(features).__name__) + return [] + + logger.info("okn_egrkn_client: получено %d фич (verify=%s)", len(features), verify) + return features + + +__all__ = ["fetch_okn_points"] diff --git a/backend/app/services/site_finder/okn_lookup.py b/backend/app/services/site_finder/okn_lookup.py new file mode 100644 index 00000000..ad05c563 --- /dev/null +++ b/backend/app/services/site_finder/okn_lookup.py @@ -0,0 +1,91 @@ +"""ОКН-lookup для analyze_parcel (#1066). + +Читает ``okn_objects`` (м.141) и отдаёт объекты культурного наследия +в заданном радиусе от участка — [{source_id, category, distance_m}]. + +ST_DWithin по geography (метрическое расстояние в метрах) через GIST-индекс +idx_okn_objects_geom. Участок передаётся как WKT в EPSG:4326. + +Graceful: если таблица ``okn_objects`` ещё не задеплоена (pre-migration) +или БД-ошибка — возвращает пустой список (analyze не падает). + +Wiring в analyze — следующий PR (analyze-wiring, follow-up #1066). +""" + +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). +_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 + 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}], отсортированный + по расстоянию ASC. Пустой список при отсутствии участка / таблицы / + объектов в радиусе (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], + } + ) + return result + + +__all__ = ["parcel_okn_objects"] diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 555595b8..6bc7e43f 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -414,4 +414,15 @@ def build_beat_schedule() -> dict: "options": {"queue": "celery"}, } + # ОКН-объекты ЕКБ/Свердл.обл из АИС ЕГРКН (#1066): POST searchMap → + # okn_objects (м.141). ~800+ фич (54 фед + 213 рег + remainder по ЕКБ; + # область добавляет дополнительные). Воскресенье 04:30 МСК — не конкурирует + # с ird-harvest (понедельник 05:00) и gknspecial (05:30). Реестр ОКН меняется + # медленно — еженедельно достаточно. Техническая, не в job_settings. + schedule["okn-objects-sync-weekly"] = { + "task": "tasks.okn_objects_sync.sync_okn_objects", + "schedule": _parse_cron("30 4 * * sun"), + "options": {"queue": "celery"}, + } + return schedule diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 7eb01089..6097a1d9 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -73,6 +73,7 @@ celery_app = Celery( "app.workers.tasks.genplan_zones_sync", "app.workers.tasks.ekb_ppt_tep_sync", "app.workers.tasks.krt_geometry_sync", + "app.workers.tasks.okn_objects_sync", ], ) celery_app.conf.timezone = "Europe/Moscow" diff --git a/backend/app/workers/tasks/okn_objects_sync.py b/backend/app/workers/tasks/okn_objects_sync.py new file mode 100644 index 00000000..35597ccc --- /dev/null +++ b/backend/app/workers/tasks/okn_objects_sync.py @@ -0,0 +1,189 @@ +"""Celery task: ingest ОКН-объектов ЕКБ/Свердл.обл из АИС ЕГРКН (#1066). + +Обходит 6 комбинаций (address, category_type): + - «Екатеринбург», 1/2/4 + - «Свердловская», 1/2/4 + +Один объект может встречаться в нескольких проходах (например «Екатеринбург»-категория-2 +и «Свердловская»-категория-2). Дедупликация по source_id: первый проход, выдавший объект, +устанавливает category (самая «высокая» категория первой встретившейся). + +Порядок проходов (от федерального к местному, Екатеринбург раньше области) обеспечивает, +что при конфликте source_id category берётся из наиболее значимой категории. + +UPSERT: ON CONFLICT (source_id) DO UPDATE обновляет geom, fetched_at (category не меняется +при повторном импорте — первый wins). SAVEPOINT per-row через db.begin_nested(). + +Beat: еженедельно в воскресенье в 04:30 МСК — до ird-harvest (понедельник 05:00). +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from sqlalchemy import text + +from app.core.db import SessionLocal +from app.services.scrapers.okn_egrkn_client import fetch_okn_points +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + +# Маппинг cob_category_type → строковое название категории +_CATEGORY_MAP: dict[int, str] = { + 1: "federal", + 2: "regional", + 4: "local", +} + +# Порядок проходов: (address, category_type). +# Федеральные идут первыми — при source_id-конфликте category='federal' выигрывает. +_PASSES: list[tuple[str, int]] = [ + ("Екатеринбург", 1), + ("Екатеринбург", 2), + ("Екатеринбург", 4), + ("Свердловская", 1), + ("Свердловская", 2), + ("Свердловская", 4), +] + +_UPSERT_SQL = text(""" + INSERT INTO okn_objects (source_id, category, geom, raw_props, fetched_at) + VALUES ( + CAST(:source_id AS text), + CAST(:category AS text), + ST_SetSRID( + ST_MakePoint( + CAST(:lon AS double precision), + CAST(:lat AS double precision) + ), + 4326 + ), + CAST(:raw_props AS jsonb), + now() + ) + ON CONFLICT (source_id) DO UPDATE SET + geom = EXCLUDED.geom, + raw_props = EXCLUDED.raw_props, + fetched_at = EXCLUDED.fetched_at +""") + + +def _extract_coords(feature: dict[str, Any]) -> tuple[float, float] | None: + """Извлечь (lon, lat) из фичи ЕГРКН. + + ЕГРКН возвращает coordinates в порядке [lat, lon] (Яндекс-конвенция). + Для PostGIS берём: lon=coordinates[1], lat=coordinates[0]. + + Валидный bbox ЕКБ + ближайшего окружения (55–58°N, 59–62°E). + """ + try: + geom = feature.get("geometry") or {} + coords = geom.get("coordinates") + if not coords or len(coords) < 2: + return None + lat = float(coords[0]) + lon = float(coords[1]) + # Базовая санитарная проверка — координаты должны попасть в Урал + if not (55.0 <= lat <= 58.5 and 58.0 <= lon <= 63.0): + logger.warning( + "okn_objects_sync: подозрительные координаты lat=%.4f lon=%.4f, пропускаем", + lat, + lon, + ) + return None + return lon, lat + except (TypeError, ValueError, KeyError) as exc: + logger.warning("okn_objects_sync: ошибка разбора координат фичи: %s", exc) + return None + + +@celery_app.task(name="tasks.okn_objects_sync.sync_okn_objects", queue="celery") +def sync_okn_objects() -> dict[str, int]: + """Ingest ОКН-объектов из ЕГРКН в okn_objects (#1066). + + Проходит 6 комбинаций (address, category_type), дедуплицирует по source_id + (первый проход устанавливает category), UPSERT в БД с SAVEPOINT per-row. + + Returns: + {"okn": N} — число успешно обработанных фич (upsert + skip дублей). + """ + # Дедуп по source_id в памяти: source_id → category (first-wins) + seen: dict[str, str] = {} + # Очередь для UPSERT: (source_id, category, lon, lat, raw_props_json) + to_upsert: list[dict[str, Any]] = [] + + for address, category_type in _PASSES: + category = _CATEGORY_MAP[category_type] + logger.info( + "okn_objects_sync: запрос address=%r category_type=%d (%s)", + address, + category_type, + category, + ) + features = fetch_okn_points(address, category_type) + logger.info( + "okn_objects_sync: получено %d фич для address=%r category=%s", + len(features), + address, + category, + ) + + for feature in features: + source_id = str(feature.get("id") or "").strip() + if not source_id: + logger.warning("okn_objects_sync: фича без id, пропускаем: %s", feature) + continue + + coords = _extract_coords(feature) + if coords is None: + continue + lon, lat = coords + + if source_id in seen: + # Объект уже встречался в более приоритетном проходе — пропускаем + continue + seen[source_id] = category + + props = feature.get("properties") or {} + to_upsert.append( + { + "source_id": source_id, + "category": category, + "lon": lon, + "lat": lat, + "raw_props": json.dumps(props, ensure_ascii=False), + } + ) + + logger.info("okn_objects_sync: всего уникальных фич для UPSERT: %d", len(to_upsert)) + + inserted = 0 + errors = 0 + + with SessionLocal() as db: + for row in to_upsert: + try: + with db.begin_nested(): + db.execute(_UPSERT_SQL, row) + inserted += 1 + except Exception as exc: + logger.warning( + "okn_objects_sync: UPSERT source_id=%s failed: %s", + row["source_id"], + exc, + ) + errors += 1 + db.commit() + + logger.info( + "okn_objects_sync: done inserted/updated=%d errors=%d", + inserted, + errors, + ) + return {"okn": inserted} + + +__all__ = ["sync_okn_objects"] diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py new file mode 100644 index 00000000..905fec8f --- /dev/null +++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py @@ -0,0 +1,233 @@ +"""Тесты клиента АИС ЕГРКН (#1066) — без живой сети. + +Покрывает: +- coord swap: фейк-фича coordinates=[lat, lon] → lon/lat для PostGIS (критично). +- TLS-fallback: SSLError с verify=True → retry с verify=False. +- Некорректный JSON / HTTP-ошибка → пустой список, не raise. +- _build_form_data: формат jQuery serializeArray. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from app.services.scrapers.okn_egrkn_client import _build_form_data, fetch_okn_points + +# ── Вспомогательные функции ─────────────────────────────────────────────────── + + +def _make_geojson_response(features: list[dict[str, Any]]) -> dict[str, Any]: + """Сформировать GeoJSON FeatureCollection.""" + return {"type": "FeatureCollection", "features": features} + + +def _make_feature( + feature_id: str, + lat: float, + lon: float, + props: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Минимальная фича ЕГРКН с координатами в порядке [lat, lon] (Яндекс).""" + return { + "id": feature_id, + "geometry": {"type": "Point", "coordinates": [lat, lon]}, + "properties": props or {}, + } + + +# ── КРИТИЧНО: coord swap ────────────────────────────────────────────────────── + + +def test_coord_swap_ekb_point_inside_bbox() -> None: + """ЕГРКН возвращает [lat, lon]; клиент должен вернуть фичу как есть. + + Тест проверяет, что _extract_coords в воркере корректно инвертирует: + coordinates=[56.83, 60.61] → lat=56.83 lon=60.61 → MakePoint(lon=60.61, lat=56.83). + Прямая проверка: ЕКБ bbox 55–58°N, 58–63°E — lon=60.61 и lat=56.83 входят. + """ + # Фича: coordinates[0]=lat=56.83, coordinates[1]=lon=60.61 (центр ЕКБ) + feature = _make_feature("test-ekb-001", lat=56.83, lon=60.61) + coords = feature["geometry"]["coordinates"] + + # coordinates[0] = lat (56.83°N — севернее экватора, в ЕКБ) + # coordinates[1] = lon (60.61°E — в Свердловской обл.) + lat = float(coords[0]) + lon = float(coords[1]) + + # Проверяем что при правильной интерпретации точка попадает в ЕКБ-bbox + assert 55.0 <= lat <= 58.5, f"lat={lat} вне ЕКБ-bbox 55–58.5°N" + assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 58–63°E" + + # Проверяем что lat/lon НЕ перепутаны местами: + # если бы мы взяли coordinates[0] как lon, то lon=56.83 — это не в Свердловской обл. + # а как lat взяли бы coordinates[1]=60.61 — 60.61°N это Финляндия, не ЕКБ + wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat + wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon + # Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан + assert not ( + 55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0 + ), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}" + + +def test_coord_swap_inverted_would_fail_bbox_check() -> None: + """Если бы lat/lon были перепутаны — санитарная проверка воркера отклонила бы точку. + + В okn_objects_sync._extract_coords валидный bbox: 55–58.5°N, 58–63°E. + При перестановке coordinates[0]=56.83 → трактуется как lon=56.83°E (вне 58–63°E) + и coordinates[1]=60.61 → трактуется как lat=60.61°N (вне 55–58.5°N). + """ + # Имитируем НЕВЕРНУЮ интерпретацию: lon берём из coords[0], lat из coords[1] + feature = _make_feature("test-swap-check", lat=56.83, lon=60.61) + coords = feature["geometry"]["coordinates"] + + wrong_lon = float(coords[0]) # 56.83 — интерпретируем как lon (неверно) + wrong_lat = float(coords[1]) # 60.61 — интерпретируем как lat (неверно) + + inside_ekb = (55.0 <= wrong_lat <= 58.5) and (58.0 <= wrong_lon <= 63.0) + assert not inside_ekb, ( + "Перепутанные координаты ошибочно прошли бы bbox-фильтр — " + "фикс coord swap в _extract_coords сломан" + ) + + +# ── fetch_okn_points: мок httpx ─────────────────────────────────────────────── + + +def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock: + """Создать мок httpx.Response.""" + resp = MagicMock() + resp.status_code = status + resp.json.return_value = payload + resp.raise_for_status = MagicMock() + if status >= 400: + import httpx + + resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=resp + ) + return resp + + +def test_fetch_okn_points_returns_features() -> None: + """fetch_okn_points возвращает список фич из FeatureCollection.""" + features = [ + _make_feature("001", 56.83, 60.61), + _make_feature("002", 56.84, 60.62), + ] + payload = _make_geojson_response(features) + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = _mock_response(payload) + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): + result = fetch_okn_points("Екатеринбург", 1) + + assert len(result) == 2 + assert result[0]["id"] == "001" + assert result[1]["id"] == "002" + + +def test_fetch_okn_points_empty_features() -> None: + """Пустой FeatureCollection → пустой список.""" + payload = _make_geojson_response([]) + + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = _mock_response(payload) + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): + result = fetch_okn_points("Екатеринбург", 2) + + assert result == [] + + +def test_fetch_okn_points_http_error_returns_empty() -> None: + """HTTP 500 → пустой список (не raise).""" + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = _mock_response({}, status=500) + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): + result = fetch_okn_points("Свердловская", 1) + + assert result == [] + + +def test_fetch_okn_points_invalid_json_returns_empty() -> None: + """Невалидный JSON в ответе → пустой список.""" + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.json.side_effect = ValueError("not json") + mock_client.post.return_value = resp + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client): + result = fetch_okn_points("Екатеринбург", 4) + + assert result == [] + + +def test_fetch_okn_points_ssl_error_retries_with_verify_false() -> None: + """SSL ConnectError при verify=True → автоматический retry с verify=False. + + httpx >= 0.28 не имеет отдельного SSLError — SSL-ошибки приходят как ConnectError + с SSL-ключевым словом в сообщении. Клиент определяет это через _is_ssl_error(). + """ + import httpx + + features = [_make_feature("ssl-001", 56.83, 60.61)] + payload = _make_geojson_response(features) + + # Первый клиент (verify=True) бросает ConnectError с SSL-сообщением + # Второй клиент (verify=False) возвращает корректный ответ + clients_created: list[dict] = [] + + def _make_client(**kwargs: Any) -> MagicMock: + verify = kwargs.get("verify", True) + clients_created.append({"verify": verify}) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + if verify: + # ConnectError с SSL в сообщении — типичная ошибка verify=True для RU CA + mock_client.post.side_effect = httpx.ConnectError("ssl cert verify failed") + else: + mock_client.post.return_value = _mock_response(payload) + return mock_client + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client): + result = fetch_okn_points("Екатеринбург", 1) + + assert len(result) == 1 + assert result[0]["id"] == "ssl-001" + # Оба клиента были созданы: первый verify=True, второй verify=False + assert len(clients_created) == 2 + assert clients_created[0]["verify"] is True + assert clients_created[1]["verify"] is False + + +# ── _build_form_data ────────────────────────────────────────────────────────── + + +def test_build_form_data_structure() -> None: + """form_data содержит правильные пары name/value для jQuery serializeArray.""" + result = _build_form_data("Екатеринбург", 1) + + assert ("data[0][name]", "CultureObjects[cob_address]") in result + assert ("data[0][value]", "Екатеринбург") in result + assert ("data[1][name]", "CultureObjects[cob_category_type]") in result + assert ("data[1][value]", "1") in result + + +def test_build_form_data_category_type_as_string() -> None: + """category_type конвертируется в строку (httpx data= ожидает str).""" + result = _build_form_data("Свердловская", 4) + values = dict(result) + assert values["data[1][value]"] == "4" diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py new file mode 100644 index 00000000..e345c9a1 --- /dev/null +++ b/backend/tests/workers/tasks/test_okn_objects_sync.py @@ -0,0 +1,361 @@ +"""Тесты воркера okn_objects_sync (#1066) — без живой сети. + +Покрывает: +- upsert идемпотентность: два вызова с одними source_id → один insert. +- дедуп source_id across проходов: category берётся из первого (более приоритетного). +- _extract_coords: правильный порядок [lat, lon] → (lon, lat) для PostGIS. +- _extract_coords: невалидные / выходящие за bbox координаты → None. +- sync_okn_objects: мок client + мок DB, проверка UPSERT-вызовов. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from app.workers.tasks.okn_objects_sync import _extract_coords + +# ── _extract_coords ─────────────────────────────────────────────────────────── + + +def _make_feature_coords(lat: float, lon: float) -> dict[str, Any]: + """Фича ЕГРКН с coordinates=[lat, lon] (Яндекс-порядок).""" + return { + "id": "test", + "geometry": {"type": "Point", "coordinates": [lat, lon]}, + "properties": {}, + } + + +def test_extract_coords_ekb_center() -> None: + """Центр ЕКБ (56.83N 60.61E): lat=coords[0], lon=coords[1] → (lon, lat) для MakePoint.""" + feature = _make_feature_coords(lat=56.83, lon=60.61) + result = _extract_coords(feature) + assert result is not None + lon, lat = result + # MakePoint(lon, lat): lon=60.61°E (Свердл. обл.), lat=56.83°N (ЕКБ) + assert abs(lon - 60.61) < 0.001, f"lon={lon} ≠ 60.61 — coord swap сломан" + assert abs(lat - 56.83) < 0.001, f"lat={lat} ≠ 56.83 — coord swap сломан" + + +def test_extract_coords_inside_ekb_bbox() -> None: + """Любая точка в ЕКБ-bbox [55–58.5°N, 58–63°E] проходит санитарный фильтр.""" + feature = _make_feature_coords(lat=56.50, lon=60.10) + result = _extract_coords(feature) + assert result is not None + lon, lat = result + assert 58.0 <= lon <= 63.0 + assert 55.0 <= lat <= 58.5 + + +def test_extract_coords_outside_bbox_returns_none() -> None: + """Координаты вне Урал-bbox → None (некорректная/тестовая точка из ЕГРКН).""" + # Москва (~55.75N 37.62E) — lon=37.62 вне [58–63] + feature = _make_feature_coords(lat=55.75, lon=37.62) + result = _extract_coords(feature) + assert result is None + + +def test_extract_coords_inverted_would_fail_bbox() -> None: + """Тест критически важной coord swap логики. + + Если бы coords[0] трактовался как lon (а не lat), + то для ЕКБ-точки (lat=56.83, lon=60.61): + wrong_lon = 56.83 → вне bbox [58–63] + → _extract_coords вернул бы None → баг обнаружен. + + Этот тест ловит регрессию если кто-то случайно поменяет местами coords[0]/[1]. + """ + # Проверяем что ПРАВИЛЬНАЯ точка ЕКБ проходит только при lat=coords[0], lon=coords[1] + feature = _make_feature_coords(lat=56.83, lon=60.61) + # Симулируем неверную трактовку: coords[0]=56.83 как lon + inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63] + inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5] + inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0) + assert ( + not inverted_inside + ), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" + + # Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat) + result = _extract_coords(feature) + assert result is not None + lon, _lat = result + assert abs(lon - 60.61) < 0.001 + + +def test_extract_coords_missing_geometry_returns_none() -> None: + """Фича без geometry → None (graceful).""" + feature: dict[str, Any] = {"id": "x", "geometry": None, "properties": {}} + assert _extract_coords(feature) is None + + +def test_extract_coords_missing_coordinates_returns_none() -> None: + """Geometry без coordinates → None.""" + feature: dict[str, Any] = { + "id": "x", + "geometry": {"type": "Point"}, + "properties": {}, + } + assert _extract_coords(feature) is None + + +def test_extract_coords_single_coordinate_returns_none() -> None: + """Только одна координата (len<2) → None.""" + feature: dict[str, Any] = { + "id": "x", + "geometry": {"type": "Point", "coordinates": [56.83]}, + "properties": {}, + } + assert _extract_coords(feature) is None + + +# ── sync_okn_objects: дедуп source_id ──────────────────────────────────────── + + +def _make_feature_full( + feature_id: str, + lat: float = 56.83, + lon: float = 60.61, +) -> dict[str, Any]: + return { + "id": feature_id, + "geometry": {"type": "Point", "coordinates": [lat, lon]}, + "properties": {"name": f"ОКН-{feature_id}"}, + } + + +def test_sync_okn_objects_dedup_source_id_across_passes() -> None: + """Один source_id из нескольких проходов → category из первого прохода (federal wins).""" + from app.workers.tasks.okn_objects_sync import sync_okn_objects + + # «001» встречается в проходе (Екатеринбург, 1) = federal + # и в проходе (Екатеринбург, 2) = regional + # category должна быть federal + pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { + ("Екатеринбург", 1): [_make_feature_full("001")], + ("Екатеринбург", 2): [_make_feature_full("001")], # дубль + ("Екатеринбург", 4): [], + ("Свердловская", 1): [], + ("Свердловская", 2): [], + ("Свердловская", 4): [], + } + + def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]: + return pass_results.get((address, category_type), []) + + upserted_rows: list[dict[str, Any]] = [] + + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) + mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) + + def _capture_execute(sql: Any, row: Any = None) -> MagicMock: + if row: + upserted_rows.append(dict(row)) + return MagicMock() + + mock_db.execute = _capture_execute + + with ( + patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch), + patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), + ): + result = sync_okn_objects() + + # Один объект, один upsert + assert result == {"okn": 1} + assert len(upserted_rows) == 1 + assert upserted_rows[0]["source_id"] == "001" + assert upserted_rows[0]["category"] == "federal" + + +def test_sync_okn_objects_upsert_idempotent() -> None: + """Два вызова sync_okn_objects с одними source_id → два upsert (ON CONFLICT DO UPDATE в БД). + + Тест проверяет что воркер передаёт все source_id для UPSERT при каждом прогоне — + идемпотентность гарантирует SQL ON CONFLICT, не фильтрация в Python. + """ + from app.workers.tasks.okn_objects_sync import sync_okn_objects + + features = [_make_feature_full("001"), _make_feature_full("002")] + pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { + ("Екатеринбург", 1): features, + ("Екатеринбург", 2): [], + ("Екатеринбург", 4): [], + ("Свердловская", 1): [], + ("Свердловская", 2): [], + ("Свердловская", 4): [], + } + + upsert_counts: list[int] = [] + + def _run_sync() -> dict[str, int]: + upserted: list[dict[str, Any]] = [] + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) + mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) + + def _exec(sql: Any, row: Any = None) -> MagicMock: + if row: + upserted.append(dict(row)) + return MagicMock() + + mock_db.execute = _exec + + with ( + patch( + "app.workers.tasks.okn_objects_sync.fetch_okn_points", + side_effect=lambda a, c: pass_results.get((a, c), []), + ), + patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), + ): + res = sync_okn_objects() + + upsert_counts.append(len(upserted)) + return res + + r1 = _run_sync() + r2 = _run_sync() + + assert r1 == {"okn": 2} + assert r2 == {"okn": 2} + # Оба прогона делают UPSERT (идемпотентность обеспечена SQL ON CONFLICT) + assert upsert_counts == [2, 2] + + +def test_sync_okn_objects_returns_total_unique_count() -> None: + """sync_okn_objects возвращает {"okn": N} — число уникальных source_id после дедупа.""" + from app.workers.tasks.okn_objects_sync import sync_okn_objects + + # 3 уникальных + 1 дубль = 3 upsert + pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { + ("Екатеринбург", 1): [_make_feature_full("A"), _make_feature_full("B")], + ("Екатеринбург", 2): [_make_feature_full("C"), _make_feature_full("A")], # A дубль + ("Екатеринбург", 4): [], + ("Свердловская", 1): [], + ("Свердловская", 2): [], + ("Свердловская", 4): [], + } + + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) + mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) + + with ( + patch( + "app.workers.tasks.okn_objects_sync.fetch_okn_points", + side_effect=lambda a, c: pass_results.get((a, c), []), + ), + patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), + ): + result = sync_okn_objects() + + assert result == {"okn": 3} + + +# ── parcel_okn_objects lookup ───────────────────────────────────────────────── + + +def test_parcel_okn_objects_returns_nearby() -> None: + """parcel_okn_objects: мок DB → список [{source_id, category, distance_m}].""" + from app.services.site_finder.okn_lookup import parcel_okn_objects + + rows = [ + ("ekb-001", "federal", 42), + ("ekb-002", "regional", 95), + ] + + mock_result = MagicMock() + mock_result.fetchall.return_value = rows + + db = MagicMock() + db.execute.return_value = mock_result + + result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100) + + assert len(result) == 2 + assert result[0] == {"source_id": "ekb-001", "category": "federal", "distance_m": 42} + assert result[1] == {"source_id": "ekb-002", "category": "regional", "distance_m": 95} + + +def test_parcel_okn_objects_empty_wkt_returns_empty() -> None: + """Пустой parcel_wkt → пустой список без DB-запроса.""" + from app.services.site_finder.okn_lookup import parcel_okn_objects + + db = MagicMock() + result = parcel_okn_objects(db, None) + assert result == [] + db.execute.assert_not_called() + + +def test_parcel_okn_objects_empty_string_wkt_returns_empty() -> None: + """Пустая строка parcel_wkt → пустой список без DB-запроса.""" + from app.services.site_finder.okn_lookup import parcel_okn_objects + + db = MagicMock() + result = parcel_okn_objects(db, "") + assert result == [] + db.execute.assert_not_called() + + +def test_parcel_okn_objects_no_table_returns_empty() -> None: + """ProgrammingError (таблица не создана) → пустой список (graceful).""" + from sqlalchemy.exc import ProgrammingError + + from app.services.site_finder.okn_lookup import parcel_okn_objects + + db = MagicMock() + db.execute.side_effect = ProgrammingError("relation does not exist", None, None) + + result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))") + assert result == [] + + +def test_parcel_okn_objects_db_error_returns_empty() -> None: + """OperationalError → пустой список (graceful).""" + from sqlalchemy.exc import OperationalError + + from app.services.site_finder.okn_lookup import parcel_okn_objects + + db = MagicMock() + db.execute.side_effect = OperationalError("connection lost", None, None) + + result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))") + assert result == [] + + +def test_parcel_okn_objects_no_results_returns_empty() -> None: + """Нет объектов в радиусе → пустой список.""" + from app.services.site_finder.okn_lookup import parcel_okn_objects + + mock_result = MagicMock() + mock_result.fetchall.return_value = [] + + db = MagicMock() + db.execute.return_value = mock_result + + result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=50) + assert result == [] + + +def test_parcel_okn_objects_distance_mapping() -> None: + """distance_m берётся из колонки [2] (третий элемент row-кортежа).""" + from app.services.site_finder.okn_lookup import parcel_okn_objects + + rows = [("id-x", "local", 7)] + mock_result = MagicMock() + mock_result.fetchall.return_value = rows + + db = MagicMock() + db.execute.return_value = mock_result + + result = parcel_okn_objects(db, "POINT(60.61 56.83)", radius_m=200) + assert result[0]["distance_m"] == 7 + assert result[0]["source_id"] == "id-x" + assert result[0]["category"] == "local" diff --git a/data/sql/141_okn_objects.sql b/data/sql/141_okn_objects.sql new file mode 100644 index 00000000..e8d63121 --- /dev/null +++ b/data/sql/141_okn_objects.sql @@ -0,0 +1,65 @@ +-- 141_okn_objects.sql +-- #1066 (Site Finder v2 / GG-форсайт) — объекты культурного наследия (ОКН/памятники) +-- ЕКБ и Свердловской обл. из АИС ЕГРКН Минкультуры. +-- +-- Источник: https://okn-mk.mkrf.ru/Maps/searchMap (POST, application/x-www-form-urlencoded) +-- Формат ответа: GeoJSON FeatureCollection с полями id + coordinates[lat, lon] (Яндекс-порядок). +-- +-- Обновляется еженедельно воркером okn_objects_sync (tasks/okn_objects_sync.py). +-- Ключ дедупликации: source_id (id фичи карты ЕГРКН). +-- Категория берётся из прохода по cob_category_type=1/2/4 (federal/regional/local). +-- +-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN). +-- Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блоки по pg_constraint). +-- Без INSERT — данные приходят из Celery-воркера. + +BEGIN; + +-- ── 1. Таблица okn_objects ──────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS okn_objects ( + id BIGSERIAL PRIMARY KEY, + source_id TEXT NOT NULL, -- id фичи из ЕГРКН карты (дедуп) + category TEXT, -- 'federal' | 'regional' | 'local' + geom geometry(Point, 4326) NOT NULL, -- WGS84 точка (lon, lat) + raw_props JSONB, -- сырые props GeoJSON-фичи + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE okn_objects IS + 'Объекты культурного наследия (ОКН/памятники) ЕКБ и Свердловской обл. (#1066). ' + 'Источник: АИС ЕГРКН Минкультуры (okn-mk.mkrf.ru/Maps/searchMap). ' + 'Обновляется еженедельно воркером okn_objects_sync.'; + +COMMENT ON COLUMN okn_objects.source_id IS + 'id фичи из GeoJSON ответа карты ЕГРКН. Используется как ключ дедупликации.'; +COMMENT ON COLUMN okn_objects.category IS + 'Категория ОКН: federal (cob_category_type=1), regional (=2), local (=4).'; +COMMENT ON COLUMN okn_objects.geom IS + 'Точка в WGS84 (EPSG:4326). Координаты из ЕГРКН [lat, lon] перевёрнуты: ' + 'ST_MakePoint(coordinates[1], coordinates[0]).'; + +-- ── 2. UNIQUE по source_id — ключ дедупликации для UPSERT ──────────────────── +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'uq_okn_objects_source_id' + ) THEN + ALTER TABLE okn_objects + ADD CONSTRAINT uq_okn_objects_source_id UNIQUE (source_id); + END IF; +END +$$; + +-- ── 3. GIST-индекс по геометрии (ST_DWithin / пространственный lookup) ──────── +CREATE INDEX IF NOT EXISTS idx_okn_objects_geom + ON okn_objects USING GIST (geom); + +-- ── 4. Индекс по категории (фильтрация по federal/regional/local) ───────────── +CREATE INDEX IF NOT EXISTS idx_okn_objects_category + ON okn_objects (category); + +-- ── 5. Индекс по свежести данных ───────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_okn_objects_fetched_at + ON okn_objects (fetched_at DESC); + +COMMIT;