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-ЗУ,
пересекающие участок (ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ + opportunity_* из #1086).
Дёшево (GIST ST_Intersects), всегда безопасно.
2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058) + КРТ (#1060).
2. ЕКБ-геопортал WFS по centroid (network, опц.): функц.зона генплана (#1058).
3. ПЗЗ-регламент зоны (#1067 C8b): zone_regulation_cache по индексу зоны (cache-first через
``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
parcels.py даже не зовёт этот модуль. Сетевой timeout укорочен (geoportal_timeout) чтобы не
держать sync-handler.
@ -21,9 +25,9 @@ parcels.py даже не зовёт этот модуль. Сетевой timeou
from __future__ import annotations
import logging
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
@ -38,6 +42,16 @@ logger = logging.getLogger(__name__)
# Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго).
_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(
client: EKBGeoportalClient, lon: float, lat: float
@ -51,14 +65,27 @@ def _functional_zone_at(
return feats[0].properties if feats else None
def _krt_at(client: EKBGeoportalClient, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060) в точке — properties всех WFS-фич. [] при отсутствии/сбое."""
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.
Возвращает список properties-dicts в той же форме, что ранее WFS:
num_oks_krt / status / full_name / area.
[] при отсутствии пересечений или если таблица пуста/не существует.
"""
try:
feats = client.features_at_point("krt", lon, lat)
except Exception as exc: # внешний геопортал, graceful
logger.warning("ird_analyze: krt WFS failed: %s", exc)
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
return [
{
"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 [f.properties for f in feats]
def _zone_regulation_at(
@ -86,7 +113,7 @@ def build_ird_analyze_block(
Args:
db: сессия.
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).
None reservations=[].
client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию
@ -98,7 +125,9 @@ def build_ird_analyze_block(
``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085),
``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062),
``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.
"""
overlay = parcel_ird_overlaps(db, geom_wkt)
@ -114,34 +143,27 @@ def build_ird_analyze_block(
# Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части.
"krt_requisites": parcel_krt_requisites(db, geom_wkt),
"functional_zone": None,
# КРТ-геометрии (#1130): теперь из DB ekb_krt_geometry, не из живого WFS.
# Заполняется ниже через _krt_at(db, lon, lat) — дёшевый DB-вызов.
"krt": [],
"zone_regulation": None,
}
# guard активен только в unit-тестах; в prod centroid_lon/lat всегда float
# (EKB-fallback в parcels.py).
# Geoportal-источники — только при валидном centroid (network, опц.).
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).
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
block["functional_zone"] = None
# Конкурентный вызов: _krt_at (чистая сеть, без Session) уходит в фоновый поток,
# _zone_regulation_at (использует db: Session) выполняется в основном потоке —
# SQLAlchemy Session не потокобезопасна, шаринг запрещён. Пока основной поток занят
# zone_regulation, krt-вызов перекрывается с ним. Итого ≈ max(krt, zr) вместо sum.
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 = []
# zone_regulation остаётся единственным живым geoportal-вызовом в hot-пути.
# Выполняем в основном потоке (Session не потокобезопасна).
client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S)
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
block["krt"] = krt_result
block["zone_regulation"] = zone_reg
return block

View file

@ -393,6 +393,16 @@ def build_beat_schedule() -> dict:
"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 →
# ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан
# меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч +

View file

@ -72,6 +72,7 @@ celery_app = Celery(
"app.workers.tasks.reservation_ingest",
"app.workers.tasks.genplan_zones_sync",
"app.workers.tasks.ekb_ppt_tep_sync",
"app.workers.tasks.krt_geometry_sync",
],
)
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)
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения.
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation + _krt_at
(module-level) и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*,
graceful-деградацию каждого источника, skip geoportal при отсутствии centroid.
#1130: krt теперь из DB (ekb_krt_geometry) — клиент WFS больше НЕ вызывается для слоя «krt».
_krt_at(db, lon, lat) мокается на уровне модуля.
"""
from __future__ import annotations
import threading
from typing import Any
from app.services.site_finder import ird_analyze
class _Feat:
def __init__(self, properties: dict[str, Any]) -> None:
self.properties = properties
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._krt = krt
self._boom = boom
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))
if self._boom:
raise RuntimeError("geoportal down")
return self._fz if layer == "functional_zone" else self._krt
return self._fz if layer == "functional_zone" else []
_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:
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:
"""Все источники → overlaps + split opportunity + КРТ + регламент.
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06) WFS не дёргается.
"""Все источники → overlaps + split opportunity + КРТ (из DB) + регламент.
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06).
krt из DB (ekb_krt_geometry), WFS больше не вызывается для слоя «krt».
"""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
@ -78,8 +79,10 @@ def test_full_block_assembly(monkeypatch: Any) -> None:
"get_or_fetch_zone_regulation",
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)
assert len(block["ird_overlaps"]) == 3
@ -90,38 +93,74 @@ def test_full_block_assembly(monkeypatch: Any) -> None:
]
# functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None
assert block["functional_zone"] is None
assert "functional_zone" not in [layer for layer, _, _ in client.calls]
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}]
# krt из DB, не из WFS — client.calls не содержит «krt»
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["planning_projects"] == _PLANNING
def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None:
"""Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели."""
"""Сбой геопортала → функц.зона None, регламент None; overlaps и krt (DB) уцелели."""
_patch_overlay(monkeypatch, _OVERLAY)
def _boom(*a: Any, **kw: Any) -> Any:
raise RuntimeError("zone reg down")
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)
assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет
assert block["functional_zone"] is None
assert block["krt"] == []
# krt из DB — возвращается даже когда geoportal недоступен
assert block["krt"] == _KRT_DB_ROWS
assert block["zone_regulation"] is None
def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None:
"""lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст)."""
def test_krt_db_failure_graceful(monkeypatch: Any) -> None:
"""Сбой DB-запроса _krt_at → krt==[], остальное в блоке сохраняется."""
_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)
assert client.calls == []
assert krt_called == []
assert block["functional_zone"] is None
assert block["krt"] == []
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:
"""Пустой overlay → пустые списки, без KeyError."""
_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, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
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"] == {}
# ---------------------------------------------------------------------------
# Concurrency tests (#1102)
# ---------------------------------------------------------------------------
def test_krt_populated_from_db(monkeypatch: Any) -> None:
"""_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:
"""krt и zone_regulation оба вызываются и попадают в block (конкурентный путь)."""
def test_krt_empty_table_returns_empty(monkeypatch: Any) -> None:
"""Пустая 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)
zone_reg_calls: list[int] = []
@ -156,59 +228,13 @@ def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None:
return {"zone_index": "Ж-3"}
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)
assert block["krt"] == [{"num_oks_krt": "КРТ-99"}]
assert block["zone_regulation"] == {"zone_index": "Ж-3"}
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 [],
)
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, "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, ключ присутствует)."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
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)
@ -267,7 +295,8 @@ def test_krt_requisites_wired_to_block(monkeypatch: Any) -> None:
"""parcel_krt_requisites возвращает реквизит → ключ krt_requisites присутствует в блоке."""
_patch_overlay(monkeypatch, _OVERLAY)
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)
@ -284,7 +313,8 @@ def test_krt_requisites_empty_when_no_overlap(monkeypatch: Any) -> None:
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: [])
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)

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;