From 9b9c6e85d3ec7f76f84c6b0fe0225566e0fa2833 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 6 Jun 2026 19:01:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf):=20parcel=5Fird=5Foverlaps=20read-side?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20analyze=20(#1092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ird_overlay_lookup.py — read-side ИРД-overlay lookup: ST_Intersects участка (WKT 4326) против ird_overlays (м.132) → ограничения + reg_numb_border + zone_index/type + группировка by_kind. Standalone (parcels.py не тронут), GIST idx_ird_overlays_geom, graceful-degrade при отсутствии таблицы. Wiring в analyze деферится в D9b flag-gated. 4 теста. Refs #1067. Co-authored-by: lekss361 Co-committed-by: lekss361 --- .../site_finder/ird_overlay_lookup.py | 82 +++++++++++++++++ .../tests/services/test_ird_overlay_lookup.py | 88 +++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 backend/app/services/site_finder/ird_overlay_lookup.py create mode 100644 backend/tests/services/test_ird_overlay_lookup.py diff --git a/backend/app/services/site_finder/ird_overlay_lookup.py b/backend/app/services/site_finder/ird_overlay_lookup.py new file mode 100644 index 00000000..a8308909 --- /dev/null +++ b/backend/app/services/site_finder/ird_overlay_lookup.py @@ -0,0 +1,82 @@ +"""ИРД-overlay lookup для analyze_parcel (#1067 D9). + +Читает ``ird_overlays`` (м.132, наполняется harvest-task #1067 B5) и отдаёт ИРД-ограничения, +пересекающие участок: ЗОУИТ / ПЗЗ-границы / ОКН / ООПТ-леса-вода — с reg_numb_border и (когда +резолвлено, #1067 C8/D9-content) читаемым типом/индексом зоны. + +Отличие от ``quarter_dump_lookup`` (nspd_quarter_dumps blob-кеш): тот отдаёт per-quarter дамп +ЗОУИТ/zoning из NSPD QuarterDump; ``ird_overlays`` — выделенный per-feature ИРД-store с +reg_numb_border + content-колонками (для геопортал-резолва ВРИ/типа). Комплементарно: канонический +ИРД-слой «волшебной таблицы». + +Graceful: если таблица ``ird_overlays`` ещё не задеплоена (pre-migration) или БД-ошибка — возвращает +пустой результат (analyze не падает). Wiring в analyze — flag-gated (D9b). +""" + +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__) + +# Пустой результат — sentinel для analyze try/except (НЕ мутировать). +IRD_EMPTY_RESULT: dict[str, Any] = {"ird_overlaps": [], "ird_by_kind": {}} + +# ИРД-overlay по участку: ST_Intersects через GIST idx_ird_overlays_geom. geom уже EPSG:4326 +# (harvest стор ST_Transform 3857→4326). Участок передаётся как WKT в 4326. CAST psycopg v3. +_IRD_OVERLAP_SQL = text( + """ + SELECT layer_kind, reg_numb_border, category, subcategory, + zone_index, zone_type_name + FROM ird_overlays + WHERE ST_Intersects(geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)) + ORDER BY layer_kind, reg_numb_border + """ +) + + +def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]: + """ИРД-ограничения, пересекающие участок. + + Args: + db: сессия. + parcel_wkt: геометрия участка в WKT (EPSG:4326). None → пустой результат. + + Returns: + {'ird_overlaps': [{layer_kind, reg_numb_border, category, subcategory, + zone_index, zone_type_name}], 'ird_by_kind': {layer_kind: count}}. + Пусто при отсутствии участка/таблицы/пересечений (analyze не падает). + """ + if not parcel_wkt: + return {"ird_overlaps": [], "ird_by_kind": {}} + try: + rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + except (OperationalError, ProgrammingError) as exc: + # Таблица ещё не задеплоена / БД-ошибка — graceful degrade. + logger.warning("parcel_ird_overlaps: ird_overlays недоступна, skip: %s", exc) + return {"ird_overlaps": [], "ird_by_kind": {}} + + overlaps: list[dict[str, Any]] = [] + by_kind: dict[str, int] = {} + for r in rows: + kind = r["layer_kind"] + overlaps.append( + { + "layer_kind": kind, + "reg_numb_border": r["reg_numb_border"], + "category": r["category"], + "subcategory": r["subcategory"], + "zone_index": r["zone_index"], + "zone_type_name": r["zone_type_name"], + } + ) + by_kind[kind] = by_kind.get(kind, 0) + 1 + return {"ird_overlaps": overlaps, "ird_by_kind": by_kind} + + +__all__ = ["IRD_EMPTY_RESULT", "parcel_ird_overlaps"] diff --git a/backend/tests/services/test_ird_overlay_lookup.py b/backend/tests/services/test_ird_overlay_lookup.py new file mode 100644 index 00000000..13eb8022 --- /dev/null +++ b/backend/tests/services/test_ird_overlay_lookup.py @@ -0,0 +1,88 @@ +"""Тесты parcel_ird_overlaps (#1067 D9) — ИРД-overlay lookup для analyze.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.exc import OperationalError + +from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps + + +class _Result: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict[str, Any]]: + return self._rows + + +class _FakeDB: + def __init__( + self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None + ) -> None: + self._rows = rows or [] + self._raise = raise_exc + + def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result: + if self._raise is not None: + raise self._raise + return _Result(self._rows) + + +_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))" + + +def test_groups_overlaps_by_kind() -> None: + rows = [ + { + "layer_kind": "zouit_okn", + "reg_numb_border": "66:41-6.41", + "category": 36940, + "subcategory": 20, + "zone_index": None, + "zone_type_name": None, + }, + { + "layer_kind": "zouit_okn", + "reg_numb_border": "66:41-6.42", + "category": 36940, + "subcategory": 20, + "zone_index": None, + "zone_type_name": None, + }, + { + "layer_kind": "territorial_zones", + "reg_numb_border": "66:41-7.10", + "category": 472819, + "subcategory": 1, + "zone_index": "Ц-1", + "zone_type_name": None, + }, + ] + res = parcel_ird_overlaps(_FakeDB(rows), _WKT) + assert len(res["ird_overlaps"]) == 3 + assert res["ird_by_kind"] == {"zouit_okn": 2, "territorial_zones": 1} + tz = next(o for o in res["ird_overlaps"] if o["layer_kind"] == "territorial_zones") + assert tz["reg_numb_border"] == "66:41-7.10" + assert tz["zone_index"] == "Ц-1" + + +def test_empty_when_no_wkt() -> None: + res = parcel_ird_overlaps(_FakeDB([{"layer_kind": "x"}]), None) + assert res == {"ird_overlaps": [], "ird_by_kind": {}} + + +def test_empty_when_no_overlap() -> None: + res = parcel_ird_overlaps(_FakeDB([]), _WKT) + assert res == {"ird_overlaps": [], "ird_by_kind": {}} + + +def test_graceful_when_table_missing() -> None: + """ird_overlays ещё не задеплоена → пусто, без падения analyze.""" + db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("no such table"))) + res = parcel_ird_overlaps(db, _WKT) + assert res == {"ird_overlaps": [], "ird_by_kind": {}}