gendesign/backend/app/workers/tasks/okn_objects_sync.py
bot-backend 50ecccca4d
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m21s
CI / backend-tests (pull_request) Successful in 6m16s
feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1066)
2026-06-07 15:47:51 +03:00

189 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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