feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1066) #1141
8 changed files with 1079 additions and 0 deletions
128
backend/app/services/scrapers/okn_egrkn_client.py
Normal file
128
backend/app/services/scrapers/okn_egrkn_client.py
Normal 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"]
|
||||
91
backend/app/services/site_finder/okn_lookup.py
Normal file
91
backend/app/services/site_finder/okn_lookup.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
189
backend/app/workers/tasks/okn_objects_sync.py
Normal file
189
backend/app/workers/tasks/okn_objects_sync.py
Normal 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 ЕКБ + ближайшего окружения (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"]
|
||||
233
backend/tests/services/scrapers/test_okn_egrkn_client.py
Normal file
233
backend/tests/services/scrapers/test_okn_egrkn_client.py
Normal 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 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"
|
||||
361
backend/tests/workers/tasks/test_okn_objects_sync.py
Normal file
361
backend/tests/workers/tasks/test_okn_objects_sync.py
Normal 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 [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"
|
||||
65
data/sql/141_okn_objects.sql
Normal file
65
data/sql/141_okn_objects.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue