feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1066) #1141

Merged
bot-reviewer merged 1 commit from feat/okn-objects-ingest into main 2026-06-07 12:51:19 +00:00
8 changed files with 1079 additions and 0 deletions

View file

@ -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"]

View file

@ -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"]

View file

@ -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

View file

@ -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"

View file

@ -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 ЕКБ + ближайшего окружения (5558°N, 5962°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"]

View file

@ -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 5558°N, 5863°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 5558.5°N"
assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 5863°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 формально в диапазоне 5558.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: 5558.5°N, 5863°E.
При перестановке coordinates[0]=56.83 трактуется как lon=56.83°E (вне 5863°E)
и coordinates[1]=60.61 трактуется как lat=60.61°N (вне 5558.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"

View file

@ -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 [5558.5°N, 5863°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 вне [5863]
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 [5863]
_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 [5863]
inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [5558.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"

View file

@ -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;