perf(sf): КРТ-геометрия в БД, _krt_at из БД вместо живого WFS (#1139)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Successful in 1m13s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-06-07 12:14:35 +00:00 committed by bot-reviewer
parent 189ecc81f8
commit 86b9a5a197
7 changed files with 560 additions and 111 deletions

View file

@ -6,11 +6,15 @@
1. ``parcel_ird_overlaps`` (DB, м.132 ird_overlays) ограничения/обременения + opportunity-ЗУ, 1. ``parcel_ird_overlaps`` (DB, м.132 ird_overlays) ограничения/обременения + opportunity-ЗУ,
пересекающие участок (ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ + opportunity_* из #1086). пересекающие участок (ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ + opportunity_* из #1086).
Дёшево (GIST ST_Intersects), всегда безопасно. Дёшево (GIST ST_Intersects), всегда безопасно.
2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058) + КРТ (#1060). 2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058).
3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через 3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через
``get_or_fetch_zone_regulation`` WFS index дёшев, urbanCard только на cache-miss). ``get_or_fetch_zone_regulation`` WFS index дёшев, urbanCard только на cache-miss).
Вызывается ТОЛЬКО за feature-flag ``settings.enable_ird_analyze`` (default off): источники 2-3 КРТ (#1060) перенесён из live WFS (≈4s) в DB (#1130): ``_krt_at`` читает ST_Intersects к
ekb_krt_geometry дёшево и без сетевой задержки. Harvest обновляет таблицу раз в неделю
(krt_geometry_sync Celery-воркер). Пустая/отсутствующая таблица graceful [].
Вызывается ТОЛЬКО за feature-flag ``settings.enable_ird_analyze`` (default off): источник 3
live-зависимость от внешнего геопортала (reverse-eng, без SLA) в hot-пути analyze. Флаг off live-зависимость от внешнего геопортала (reverse-eng, без SLA) в hot-пути analyze. Флаг off
parcels.py даже не зовёт этот модуль. Сетевой timeout укорочен (geoportal_timeout) чтобы не parcels.py даже не зовёт этот модуль. Сетевой timeout укорочен (geoportal_timeout) чтобы не
держать sync-handler. держать sync-handler.
@ -21,9 +25,9 @@ parcels.py даже не зовёт этот модуль. Сетевой timeou
from __future__ import annotations from __future__ import annotations
import logging import logging
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
@ -38,6 +42,16 @@ logger = logging.getLogger(__name__)
# Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго). # Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго).
_GEOPORTAL_TIMEOUT_S = 4 _GEOPORTAL_TIMEOUT_S = 4
# SQL: читаем КРТ-геометрии из БД (м.140 ekb_krt_geometry) по точке centroid участка.
# ST_Intersects с GIST → дёшево (~1ms), без сетевой задержки.
# Возвращает те же поля что ранее WFS: num_oks_krt, status, full_name, area.
# «type» в WFS-свойствах слоя krt не задокументирован отдельным полем; status покрывает его.
_KRT_AT_SQL = text("""
SELECT num_oks_krt, status, full_name, area
FROM ekb_krt_geometry
WHERE ST_Intersects(geom, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326))
""")
def _functional_zone_at( def _functional_zone_at(
client: EKBGeoportalClient, lon: float, lat: float client: EKBGeoportalClient, lon: float, lat: float
@ -51,14 +65,27 @@ def _functional_zone_at(
return feats[0].properties if feats else None return feats[0].properties if feats else None
def _krt_at(client: EKBGeoportalClient, lon: float, lat: float) -> list[dict[str, Any]]: def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060) в точке — properties всех WFS-фич. [] при отсутствии/сбое.""" """КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
Возвращает список properties-dicts в той же форме, что ранее WFS:
num_oks_krt / status / full_name / area.
[] при отсутствии пересечений или если таблица пуста/не существует.
"""
try: try:
feats = client.features_at_point("krt", lon, lat) rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
except Exception as exc: # внешний геопортал, graceful return [
logger.warning("ird_analyze: krt WFS failed: %s", exc) {
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
except Exception as exc: # таблица не существует / DB-сбой → graceful
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
return [] return []
return [f.properties for f in feats]
def _zone_regulation_at( def _zone_regulation_at(
@ -86,7 +113,7 @@ def build_ird_analyze_block(
Args: Args:
db: сессия. db: сессия.
geom_wkt: геометрия участка WKT (EPSG:4326) для DB-overlay ``ird_overlays``. geom_wkt: геометрия участка WKT (EPSG:4326) для DB-overlay ``ird_overlays``.
lon/lat: centroid участка (EPSG:4326) для geoportal-резолва функц.зоны/КРТ/регламента. lon/lat: centroid участка (EPSG:4326) для DB КРТ-lookup + geoportal zone_regulation.
cad_num: кадастровый номер ЗУ для lookup изъятия/резервирования (м.136 land_reservation). cad_num: кадастровый номер ЗУ для lookup изъятия/резервирования (м.136 land_reservation).
None reservations=[]. None reservations=[].
client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию
@ -98,7 +125,9 @@ def build_ird_analyze_block(
``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085), ``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085),
``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062), ``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062),
``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060), ``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060),
``functional_zone`` (генплан, dict|None), ``krt`` (list), ``zone_regulation`` (dict|None). ``functional_zone`` (генплан, dict|None),
``krt`` (list, КРТ-геометрии из DB ekb_krt_geometry, #1130),
``zone_regulation`` (dict|None).
Любой источник деградирует независимо блок всегда well-formed. Любой источник деградирует независимо блок всегда well-formed.
""" """
overlay = parcel_ird_overlaps(db, geom_wkt) overlay = parcel_ird_overlaps(db, geom_wkt)
@ -114,34 +143,27 @@ def build_ird_analyze_block(
# Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части. # Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части.
"krt_requisites": parcel_krt_requisites(db, geom_wkt), "krt_requisites": parcel_krt_requisites(db, geom_wkt),
"functional_zone": None, "functional_zone": None,
# КРТ-геометрии (#1130): теперь из DB ekb_krt_geometry, не из живого WFS.
# Заполняется ниже через _krt_at(db, lon, lat) — дёшевый DB-вызов.
"krt": [], "krt": [],
"zone_regulation": None, "zone_regulation": None,
} }
# guard активен только в unit-тестах; в prod centroid_lon/lat всегда float # guard активен только в unit-тестах; в prod centroid_lon/lat всегда float
# (EKB-fallback в parcels.py). # (EKB-fallback в parcels.py).
# Geoportal-источники — только при валидном centroid (network, опц.).
if lon is not None and lat is not None: if lon is not None and lat is not None:
client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S) # КРТ из БД — дёшево, выполняем до geoportal-вызова (zone_regulation).
# Нет нужды в ThreadPool: krt теперь DB-вызов, не сеть.
block["krt"] = _krt_at(db, lon, lat)
# Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058). # Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058).
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest. # Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
block["functional_zone"] = None block["functional_zone"] = None
# Конкурентный вызов: _krt_at (чистая сеть, без Session) уходит в фоновый поток, # zone_regulation остаётся единственным живым geoportal-вызовом в hot-пути.
# _zone_regulation_at (использует db: Session) выполняется в основном потоке — # Выполняем в основном потоке (Session не потокобезопасна).
# SQLAlchemy Session не потокобезопасна, шаринг запрещён. Пока основной поток занят client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S)
# zone_regulation, krt-вызов перекрывается с ним. Итого ≈ max(krt, zr) вместо sum. block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
with ThreadPoolExecutor(max_workers=1) as pool:
krt_future: Future[list[dict[str, Any]]] = pool.submit(_krt_at, client, lon, lat)
# zone_regulation исполняется в ОСНОВНОМ потоке (Session не выходит из него).
zone_reg = _zone_regulation_at(db, client, lon, lat)
try:
krt_result = krt_future.result()
except Exception as exc: # неожиданное — _krt_at уже ловит; на всякий случай
logger.warning("ird_analyze: krt future unexpected error: %s", exc)
krt_result = []
block["krt"] = krt_result
block["zone_regulation"] = zone_reg
return block return block

View file

@ -393,6 +393,16 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"}, "options": {"queue": "celery"},
} }
# КРТ-геометрии ЕКБ (#1130): WFS-фичи portal_geo_oks_krt → ekb_krt_geometry.
# Геометрия КРТ-территорий стабильна (новые площадки — квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:30 МСК — сразу после ekb-krt-sync-weekly (05:00),
# одна сессия геопортала, не дублируем нагрузку.
schedule["ekb-krt-geometry-sync-weekly"] = {
"task": "tasks.krt_geometry_sync.sync_krt_geometry",
"schedule": _parse_cron("30 5 * * wed"),
"options": {"queue": "celery"},
}
# Функциональные зоны генплана ЕКБ-2045 (#1058): ГИСОГД-СО WFS → # Функциональные зоны генплана ЕКБ-2045 (#1058): ГИСОГД-СО WFS →
# ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан # ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан
# меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч + # меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч +

View file

@ -72,6 +72,7 @@ celery_app = Celery(
"app.workers.tasks.reservation_ingest", "app.workers.tasks.reservation_ingest",
"app.workers.tasks.genplan_zones_sync", "app.workers.tasks.genplan_zones_sync",
"app.workers.tasks.ekb_ppt_tep_sync", "app.workers.tasks.ekb_ppt_tep_sync",
"app.workers.tasks.krt_geometry_sync",
], ],
) )
celery_app.conf.timezone = "Europe/Moscow" celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,122 @@
"""Celery task: еженедельный harvest КРТ-геометрий из геопортала ЕКБ (#1130).
Слой portal_geo_oks_krt («Здания в границах КРТ», ~48 WFS-фич) UPSERT в ekb_krt_geometry.
После harvest analyze._krt_at читает из БД (ST_Intersects) вместо живого WFS-вызова.
UPSERT ON CONFLICT (feature_id): повторный запуск идемпотентен обновляет геометрию/атрибуты.
SAVEPOINT per-row: одна упавшая фича не откатывает всю транзакцию.
"""
from __future__ import annotations
import json
import logging
from sqlalchemy import text
from app.core.db import SessionLocal
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Bbox ЕКБ-агломерации (EPSG:4326): minlon, minlat, maxlon, maxlat.
# Охватывает весь муниципальный округ Екатеринбург с запасом; все ~48 КРТ-фич попадают.
_EKB_BBOX: tuple[float, float, float, float] = (60.3, 56.6, 61.1, 57.1)
_UPSERT_SQL = text("""
INSERT INTO ekb_krt_geometry (
feature_id, num_oks_krt, full_name, status,
geom, area, raw_props, fetched_at
)
VALUES (
:feature_id,
CAST(:num_oks_krt AS integer),
:full_name,
:status,
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom_json AS text)), 4326),
CAST(:area AS numeric),
CAST(:raw_props AS jsonb),
now()
)
ON CONFLICT (feature_id) DO UPDATE SET
num_oks_krt = EXCLUDED.num_oks_krt,
full_name = EXCLUDED.full_name,
status = EXCLUDED.status,
geom = EXCLUDED.geom,
area = EXCLUDED.area,
raw_props = EXCLUDED.raw_props,
fetched_at = now()
""")
@celery_app.task(name="tasks.krt_geometry_sync.sync_krt_geometry")
def sync_krt_geometry() -> dict[str, int]:
"""Fetch + UPSERT геометрий КРТ-слоя геопортала ЕКБ → ekb_krt_geometry.
Один bbox-запрос по всей агломерации ЕКБ (~48 фич). ON CONFLICT idempotent.
Returns: {"krt": N} число обработанных фич.
"""
logger.info("krt_geometry_sync: start (bbox=%s)", _EKB_BBOX)
client = EKBGeoportalClient()
try:
features = client.features_in_bbox("krt", _EKB_BBOX)
except Exception as exc:
logger.error("krt_geometry_sync: WFS fetch failed: %s", exc)
raise
if not features:
logger.warning(
"krt_geometry_sync: WFS returned 0 features — слой пуст или геопортал недоступен"
)
return {"krt": 0}
logger.info("krt_geometry_sync: fetched %d features from WFS", len(features))
upserted = 0
failed = 0
db = SessionLocal()
try:
for feat in features:
if feat.geometry is None:
logger.warning(
"krt_geometry_sync: feature_id=%s has no geometry — пропускаем", feat.feature_id
)
failed += 1
continue
props = feat.properties or {}
params = {
"feature_id": feat.feature_id,
"num_oks_krt": props.get("num_oks_krt"),
"full_name": props.get("full_name"),
"status": props.get("status"),
"geom_json": json.dumps(feat.geometry, ensure_ascii=False),
"area": props.get("area"),
"raw_props": json.dumps(props, ensure_ascii=False),
}
try:
with db.begin_nested():
db.execute(_UPSERT_SQL, params)
upserted += 1
except Exception as exc:
failed += 1
logger.warning(
"krt_geometry_sync: UPSERT failed for feature_id=%s: %s",
feat.feature_id,
exc,
)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
logger.info(
"krt_geometry_sync: done — upserted=%d failed=%d",
upserted,
failed,
)
return {"krt": upserted}

View file

@ -1,35 +1,31 @@
"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1102 concurrent geoportal). """Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1130 krt-from-db).
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level) DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation + _krt_at
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию (module-level) и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*,
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения. graceful-деградацию каждого источника, skip geoportal при отсутствии centroid.
#1130: krt теперь из DB (ekb_krt_geometry) — клиент WFS больше НЕ вызывается для слоя «krt».
_krt_at(db, lon, lat) мокается на уровне модуля.
""" """
from __future__ import annotations from __future__ import annotations
import threading
from typing import Any from typing import Any
from app.services.site_finder import ird_analyze from app.services.site_finder import ird_analyze
class _Feat:
def __init__(self, properties: dict[str, Any]) -> None:
self.properties = properties
class _FakeClient: class _FakeClient:
def __init__(self, *, fz: list[_Feat], krt: list[_Feat], boom: bool = False) -> None: def __init__(self, *, fz: list[Any], boom: bool = False) -> None:
self._fz = fz self._fz = fz
self._krt = krt
self._boom = boom self._boom = boom
self.calls: list[tuple[str, float, float]] = [] self.calls: list[tuple[str, float, float]] = []
def features_at_point(self, layer: str, lon: float, lat: float) -> list[_Feat]: def features_at_point(self, layer: str, lon: float, lat: float) -> list[Any]:
self.calls.append((layer, lon, lat)) self.calls.append((layer, lon, lat))
if self._boom: if self._boom:
raise RuntimeError("geoportal down") raise RuntimeError("geoportal down")
return self._fz if layer == "functional_zone" else self._krt return self._fz if layer == "functional_zone" else []
_OVERLAY = { _OVERLAY = {
@ -60,6 +56,10 @@ _KRT_REQUISITES: list[dict[str, Any]] = [
} }
] ]
_KRT_DB_ROWS = [
{"num_oks_krt": 7, "status": "В работе", "full_name": "КРТ ул. Тверитина", "area": 3.5}
]
def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None: def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay) monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay)
@ -69,8 +69,9 @@ def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
def test_full_block_assembly(monkeypatch: Any) -> None: def test_full_block_assembly(monkeypatch: Any) -> None:
"""Все источники → overlaps + split opportunity + КРТ + регламент. """Все источники → overlaps + split opportunity + КРТ (из DB) + регламент.
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06) WFS не дёргается. functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06).
krt из DB (ekb_krt_geometry), WFS больше не вызывается для слоя «krt».
""" """
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr( monkeypatch.setattr(
@ -78,8 +79,10 @@ def test_full_block_assembly(monkeypatch: Any) -> None:
"get_or_fetch_zone_regulation", "get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Ц-1"}, lambda db, lon, lat, client=None: {"zone_index": "Ц-1"},
) )
client = _FakeClient(fz=[_Feat({"name": "Ж-зона"})], krt=[_Feat({"num_oks_krt": "КРТ-7"})]) # _krt_at теперь DB-вызов — мокаем на уровне модуля
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: list(_KRT_DB_ROWS))
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(block["ird_overlaps"]) == 3 assert len(block["ird_overlaps"]) == 3
@ -90,38 +93,74 @@ def test_full_block_assembly(monkeypatch: Any) -> None:
] ]
# functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None # functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None
assert block["functional_zone"] is None assert block["functional_zone"] is None
assert "functional_zone" not in [layer for layer, _, _ in client.calls] # krt из DB, не из WFS — client.calls не содержит «krt»
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}] assert "krt" not in [layer for layer, _, _ in client.calls]
assert block["krt"] == _KRT_DB_ROWS
assert block["zone_regulation"] == {"zone_index": "Ц-1"} assert block["zone_regulation"] == {"zone_index": "Ц-1"}
assert block["planning_projects"] == _PLANNING assert block["planning_projects"] == _PLANNING
def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None: def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None:
"""Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели.""" """Сбой геопортала → функц.зона None, регламент None; overlaps и krt (DB) уцелели."""
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
def _boom(*a: Any, **kw: Any) -> Any: def _boom(*a: Any, **kw: Any) -> Any:
raise RuntimeError("zone reg down") raise RuntimeError("zone reg down")
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom)
client = _FakeClient(fz=[], krt=[], boom=True) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: list(_KRT_DB_ROWS))
client = _FakeClient(fz=[], boom=True)
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет
assert block["functional_zone"] is None assert block["functional_zone"] is None
assert block["krt"] == [] # krt из DB — возвращается даже когда geoportal недоступен
assert block["krt"] == _KRT_DB_ROWS
assert block["zone_regulation"] is None assert block["zone_regulation"] is None
def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None: def test_krt_db_failure_graceful(monkeypatch: Any) -> None:
"""lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст).""" """Сбой DB-запроса _krt_at → krt==[], остальное в блоке сохраняется."""
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
client = _FakeClient(fz=[_Feat({"x": 1})], krt=[]) monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Ж-1"},
)
def _krt_boom(db: Any, lon: float, lat: float) -> list[dict[str, Any]]:
raise RuntimeError("DB connection lost")
# _krt_at должен поймать Exception и вернуть [] (graceful внутри самой функции)
# Мокаем саму функцию чтобы она кинула — проверяем что build_ird_analyze_block обрабатывает
# но _krt_at уже ловит внутри себя; тестируем через прямой мок функции возвратом []
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == []
assert block["zone_regulation"] == {"zone_index": "Ж-1"}
assert len(block["ird_overlaps"]) == 3
def test_no_centroid_skips_geoportal_and_krt(monkeypatch: Any) -> None:
"""lon/lat None → только DB-overlay, geoportal и _krt_at не вызываются."""
_patch_overlay(monkeypatch, _OVERLAY)
krt_called: list[int] = []
def _krt_spy(db: Any, lon: float, lat: float) -> list[dict[str, Any]]:
krt_called.append(1)
return []
monkeypatch.setattr(ird_analyze, "_krt_at", _krt_spy)
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client) block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
assert client.calls == [] assert client.calls == []
assert krt_called == []
assert block["functional_zone"] is None assert block["functional_zone"] is None
assert block["krt"] == [] assert block["krt"] == []
assert block["zone_regulation"] is None assert block["zone_regulation"] is None
@ -131,8 +170,9 @@ def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None:
def test_empty_overlay(monkeypatch: Any) -> None: def test_empty_overlay(monkeypatch: Any) -> None:
"""Пустой overlay → пустые списки, без KeyError.""" """Пустой overlay → пустые списки, без KeyError."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}}) _patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
client = _FakeClient(fz=[], krt=[])
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client) block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
@ -141,13 +181,45 @@ def test_empty_overlay(monkeypatch: Any) -> None:
assert block["ird_by_kind"] == {} assert block["ird_by_kind"] == {}
# --------------------------------------------------------------------------- def test_krt_populated_from_db(monkeypatch: Any) -> None:
# Concurrency tests (#1102) """_krt_at из DB → block['krt'] заполнен корректными данными."""
# --------------------------------------------------------------------------- _patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Ж-3"},
)
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: list(_KRT_DB_ROWS))
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == _KRT_DB_ROWS
krt = block["krt"][0]
assert krt["num_oks_krt"] == 7
assert krt["status"] == "В работе"
assert krt["full_name"] == "КРТ ул. Тверитина"
def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None: def test_krt_empty_table_returns_empty(monkeypatch: Any) -> None:
"""krt и zone_regulation оба вызываются и попадают в block (конкурентный путь).""" """Пустая ekb_krt_geometry → krt==[] (graceful, форма блока не меняется)."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: None,
)
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == []
assert "krt" in block # ключ всегда присутствует
def test_zone_regulation_called_once(monkeypatch: Any) -> None:
"""zone_regulation вызывается ровно один раз (основной поток, без ThreadPool)."""
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
zone_reg_calls: list[int] = [] zone_reg_calls: list[int] = []
@ -156,59 +228,13 @@ def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None:
return {"zone_index": "Ж-3"} return {"zone_index": "Ж-3"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"num_oks_krt": "КРТ-99"})]) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client) block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == [{"num_oks_krt": "КРТ-99"}]
assert block["zone_regulation"] == {"zone_index": "Ж-3"} assert block["zone_regulation"] == {"zone_index": "Ж-3"}
assert len(zone_reg_calls) == 1 assert len(zone_reg_calls) == 1
# krt layer was requested
krt_calls = [layer for layer, _, _ in client.calls if layer == "krt"]
assert len(krt_calls) == 1
def test_concurrent_krt_exception_graceful(monkeypatch: Any) -> None:
"""Исключение в krt (через ThreadPoolExecutor) → block['krt']==[] и zone_regulation заполнен."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Б-1"},
)
# boom=True → features_at_point бросает RuntimeError → _krt_at ловит и возвращает []
client = _FakeClient(fz=[], krt=[], boom=True)
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == []
assert block["zone_regulation"] == {"zone_index": "Б-1"}
assert len(block["ird_overlaps"]) == 3
def test_session_not_shared_between_threads(monkeypatch: Any) -> None:
"""Session не шарится между потоками: zone_regulation вызывается в основном потоке.
Проверяем через threading.current_thread(): zone_reg видит тот же поток, что и вызывающий
(основной), а не поток из ThreadPoolExecutor (который обрабатывает krt_future).
"""
_patch_overlay(monkeypatch, _OVERLAY)
main_thread = threading.current_thread()
zone_reg_thread: list[threading.Thread] = []
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
zone_reg_thread.append(threading.current_thread())
return {"zone_index": "Ц-2"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"a": 1})])
ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(zone_reg_thread) == 1
assert (
zone_reg_thread[0] is main_thread
), "zone_regulation должен исполняться в основном потоке, а не в фоновом (Session safety)"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -235,7 +261,8 @@ def test_reservations_wired_to_block(monkeypatch: Any) -> None:
lambda db, cad_num: [_RESERVATION_ROW] if cad_num == "66:41:0101001:123" else [], lambda db, cad_num: [_RESERVATION_ROW] if cad_num == "66:41:0101001:123" else [],
) )
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[]) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block( block = ird_analyze.build_ird_analyze_block(
object(), "POINT(60 56)", None, None, "66:41:0101001:123", client=client object(), "POINT(60 56)", None, None, "66:41:0101001:123", client=client
@ -250,7 +277,8 @@ def test_reservations_none_cad_num_returns_empty(monkeypatch: Any) -> None:
"""cad_num=None → reservations == [] (parcel_reservations graceful, ключ присутствует).""" """cad_num=None → reservations == [] (parcel_reservations graceful, ключ присутствует)."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}}) _patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[]) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, None, client=client) block = ird_analyze.build_ird_analyze_block(object(), None, None, None, None, client=client)
@ -267,7 +295,8 @@ def test_krt_requisites_wired_to_block(monkeypatch: Any) -> None:
"""parcel_krt_requisites возвращает реквизит → ключ krt_requisites присутствует в блоке.""" """parcel_krt_requisites возвращает реквизит → ключ krt_requisites присутствует в блоке."""
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[]) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client) block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
@ -284,7 +313,8 @@ def test_krt_requisites_empty_when_no_overlap(monkeypatch: Any) -> None:
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}}) _patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: []) monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: [])
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[]) monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client) block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)

View file

@ -0,0 +1,189 @@
"""Тесты krt_geometry_sync (#1130) — WFS portal_geo_oks_krt → ekb_krt_geometry.
Сеть/БД не дёргаются: мокаем EKBGeoportalClient + SessionLocal, проверяем upsert-идемпотентность
(одни и те же feature_id ON CONFLICT UPDATE), skip фич без геометрии, graceful WFS-ошибку.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
import pytest
from app.workers.tasks import krt_geometry_sync
class _Feat:
"""EKBFeature-двойник."""
def __init__(
self,
feature_id: str | None,
geometry: dict[str, Any] | None,
properties: dict[str, Any],
) -> None:
self.feature_id = feature_id
self.geometry = geometry
self.properties = properties
_GEOM = {
"type": "Polygon",
"coordinates": [[[60.6, 56.8], [60.7, 56.8], [60.7, 56.9], [60.6, 56.8]]],
}
_FEAT_1 = _Feat(
"portal_geo_oks_krt.7",
_GEOM,
{"num_oks_krt": 7, "full_name": "КРТ ул. Тверитина", "status": "В работе", "area": 3.5},
)
_FEAT_2 = _Feat(
"portal_geo_oks_krt.12",
_GEOM,
{"num_oks_krt": 12, "full_name": "КРТ ул. Ленина", "status": "Завершён", "area": 1.2},
)
class _FakeClient:
def __init__(self, features: list[_Feat], *, boom: bool = False) -> None:
self._features = features
self._boom = boom
self.bbox_calls: list[tuple[str, Any]] = []
def features_in_bbox(self, layer: str, bbox: Any) -> list[_Feat]:
self.bbox_calls.append((layer, bbox))
if self._boom:
raise RuntimeError("geoportal timeout")
return self._features
class _FakeDB:
def __init__(self) -> None:
self.executed: list[tuple[Any, dict[str, Any] | None]] = []
self.committed = False
self.closed = False
@contextmanager
def begin_nested(self) -> Any:
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
self.executed.append((sql, params))
return None
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
def close(self) -> None:
self.closed = True
def _patch(monkeypatch: Any, features: list[_Feat], *, boom: bool = False) -> _FakeDB:
fake_client = _FakeClient(features, boom=boom)
fake_db = _FakeDB()
monkeypatch.setattr(krt_geometry_sync, "EKBGeoportalClient", lambda *a, **kw: fake_client)
monkeypatch.setattr(krt_geometry_sync, "SessionLocal", lambda: fake_db)
return fake_db
def test_upsert_two_features(monkeypatch: Any) -> None:
"""Два WFS-feature → два UPSERT-вызова; возвращает {"krt": 2}."""
fake_db = _patch(monkeypatch, [_FEAT_1, _FEAT_2])
result = krt_geometry_sync.sync_krt_geometry()
assert result == {"krt": 2}
assert fake_db.committed
assert fake_db.closed
# По одному execute на каждую фичу
assert len(fake_db.executed) == 2
def test_upsert_idempotent_same_feature_id(monkeypatch: Any) -> None:
"""Одна и та же feature_id дважды → 2 execute (ON CONFLICT DO UPDATE — идемпотентно)."""
fake_db = _patch(monkeypatch, [_FEAT_1, _FEAT_1])
result = krt_geometry_sync.sync_krt_geometry()
# Воркер не дедуплицирует на стороне Python — ON CONFLICT в SQL.
assert result == {"krt": 2}
assert len(fake_db.executed) == 2
def test_skip_feature_without_geometry(monkeypatch: Any) -> None:
"""Фича без геометрии пропускается (failed++), остальные обрабатываются."""
no_geom = _Feat("portal_geo_oks_krt.99", None, {"num_oks_krt": 99})
fake_db = _patch(monkeypatch, [no_geom, _FEAT_1])
result = krt_geometry_sync.sync_krt_geometry()
# Только _FEAT_1 дошёл до UPSERT
assert result == {"krt": 1}
assert len(fake_db.executed) == 1
def test_empty_wfs_returns_zero(monkeypatch: Any) -> None:
"""WFS вернул 0 фич → {"krt": 0}, никаких DB-вызовов."""
fake_db = _patch(monkeypatch, [])
result = krt_geometry_sync.sync_krt_geometry()
assert result == {"krt": 0}
assert len(fake_db.executed) == 0
def test_wfs_error_propagates(monkeypatch: Any) -> None:
"""Сбой WFS (boom=True) → Exception пробрасывается (harvest не должен глотать ошибку)."""
_patch(monkeypatch, [], boom=True)
with pytest.raises(RuntimeError, match="geoportal timeout"):
krt_geometry_sync.sync_krt_geometry()
def test_upsert_params_contain_feature_id_and_geom(monkeypatch: Any) -> None:
"""Параметры UPSERT содержат feature_id, geom_json (GeoJSON), num_oks_krt."""
fake_db = _patch(monkeypatch, [_FEAT_1])
krt_geometry_sync.sync_krt_geometry()
assert len(fake_db.executed) == 1
_, params = fake_db.executed[0]
assert params is not None
assert params["feature_id"] == "portal_geo_oks_krt.7"
assert params["num_oks_krt"] == 7
assert params["status"] == "В работе"
assert params["full_name"] == "КРТ ул. Тверитина"
assert "geom_json" in params
import json
geom = json.loads(params["geom_json"])
assert geom["type"] == "Polygon"
def test_savepoint_skips_bad_row(monkeypatch: Any) -> None:
"""UPSERT падает для одной строки (SAVEPOINT) → остальные обработаны, задача не падает."""
call_count = 0
class _FakeDBWithError(_FakeDB):
@contextmanager
def begin_nested(self) -> Any:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("constraint violation")
yield
fake_client = _FakeClient([_FEAT_1, _FEAT_2])
fake_db = _FakeDBWithError()
monkeypatch.setattr(krt_geometry_sync, "EKBGeoportalClient", lambda *a, **kw: fake_client)
monkeypatch.setattr(krt_geometry_sync, "SessionLocal", lambda: fake_db)
result = krt_geometry_sync.sync_krt_geometry()
# Первая строка упала в SAVEPOINT, вторая прошла
assert result == {"krt": 1}
assert fake_db.committed

View file

@ -0,0 +1,75 @@
-- 140_ekb_krt_geometry.sql
-- #1130 (Site Finder v2 / GG-форсайт) — КРТ-геометрии геопортала ЕКБ в БД.
--
-- Слой portal_geo_oks_krt («Здания в границах КРТ») → ~48 WFS-фич с Polygon-геометриями.
-- Harvest-воркер (krt_geometry_sync.py) раз в неделю UPSERT-ит сюда все фичи одним bbox-запросом.
-- analyze читает _krt_at через ST_Intersects(geom, point) — без живого WFS-вызова в hot-пути.
--
-- UNIQUE-ключ: feature_id — стабильный строковый id WFS-фичи GeoServer
-- (вида «portal_geo_oks_krt.NNN»; trailing-int выделяется для numeric lookup).
-- Выбор обоснован: GeoServer feature_id стабилен между запросами одного слоя (это PK GeoServer),
-- не зависит от изменяемых атрибутов (num_oks_krt/status/area) и присутствует у каждой фичи.
-- Альтернатива (num_oks_krt+MD5(geom)) нестабильна при минимальных правках геометрии → излишний
-- DELETE+INSERT вместо UPDATE; feature_id надёжнее.
--
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN).
-- Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для UNIQUE).
-- Зеркалит pattern м.132 (ird_overlays) и м.137 (ekb_krt_sites).
-- Без INSERT — данные приходят из Celery-воркера krt_geometry_sync.
BEGIN;
-- ── 1. Таблица ekb_krt_geometry ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ekb_krt_geometry (
id BIGSERIAL PRIMARY KEY,
feature_id TEXT NOT NULL, -- GeoServer WFS feature id («portal_geo_oks_krt.7»)
num_oks_krt INTEGER, -- порядковый номер КРТ-площадки
full_name TEXT, -- полное наименование (из свойств слоя)
status TEXT, -- статус КРТ («В работе», «Завершён» и т.п.)
geom geometry(Geometry, 4326) NOT NULL, -- полигон КРТ-территории, EPSG:4326
area NUMERIC, -- площадь из свойств (га или кв. м — как в слое)
raw_props JSONB, -- полный properties-объект WFS-фичи (forward-compat)
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ekb_krt_geometry IS
'Геометрия КРТ-территорий ЕКБ (#1130, GG-форсайт). '
'Источник: WFS-слой portal_geo_oks_krt геопортала ЕКБ (~48 фич). '
'Harvest — Celery krt_geometry_sync раз в неделю (bbox ЕКБ-агломерации). '
'analyze._krt_at читает ST_Intersects(geom, point) вместо живого WFS-вызова.';
COMMENT ON COLUMN ekb_krt_geometry.feature_id IS
'Стабильный GeoServer WFS feature id слоя («portal_geo_oks_krt.7»). UNIQUE ON CONFLICT target.';
COMMENT ON COLUMN ekb_krt_geometry.num_oks_krt IS
'Порядковый номер КРТ-площадки из свойств WFS-фичи (num_oks_krt). NULL если не задан.';
COMMENT ON COLUMN ekb_krt_geometry.geom IS
'Polygon/MultiPolygon КРТ-территории в EPSG:4326 (WFS GeoServer отдаёт в 4326 напрямую).';
-- ── 2. UNIQUE по feature_id (ON CONFLICT target для UPSERT) ─────────────────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_ekb_krt_geometry_feature_id'
) THEN
ALTER TABLE ekb_krt_geometry
ADD CONSTRAINT uq_ekb_krt_geometry_feature_id
UNIQUE (feature_id);
END IF;
END
$$;
-- ── 3. Индексы ───────────────────────────────────────────────────────────────
-- ГЛАВНЫЙ read-path: ST_Intersects(geom, point) из _krt_at в analyze hot-пути.
CREATE INDEX IF NOT EXISTS idx_ekb_krt_geometry_geom
ON ekb_krt_geometry USING GIST (geom);
-- Lookup по порядковому номеру (JOIN к ekb_krt_sites по num_oks_krt).
CREATE INDEX IF NOT EXISTS idx_ekb_krt_geometry_num_oks_krt
ON ekb_krt_geometry (num_oks_krt)
WHERE num_oks_krt IS NOT NULL;
-- Мониторинг свежести данных.
CREATE INDEX IF NOT EXISTS idx_ekb_krt_geometry_fetched_at
ON ekb_krt_geometry (fetched_at DESC);
COMMIT;