feat(sf): EKB geoportal urbanCard — ПЗЗ-регламент content (#1067 C8)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m1s
CI / backend-tests (pull_request) Successful in 5m54s

Расширение ekb_geoportal_client: рич ПЗЗ-регламент (ИРД-content для #1059).
searchByGeom (точка → ключи UrbanTerZone/GknParcel) → urbanCard (ключи → карточка) →
parse_zone_regulation → ZoneRegulation {zone_index, zone_full_name, main/conditional/
auxiliary_vri (коды 540н), limit_params (предельные параметры текстом)}. Метод
zone_regulation_at(lon,lat). Shape подтверждён live 2026-06-06 (zone Ц-1, 24 ВРИ).
Кэшировать по zone_index (регламент идентичен для всех участков зоны) — C8b (таблица).

Refs #1067 (C8). Тесты: 11 passed (parse + pipeline + empty/none ветки).
This commit is contained in:
bot-backend 2026-06-06 21:59:38 +03:00
parent a15265c594
commit 5f0bd817f7
2 changed files with 211 additions and 2 deletions

View file

@ -16,12 +16,13 @@ X=lon, Y=lat. `geoloc_orig` (МСК-66) НЕ запрашиваем.
Источник разведки (live-verified 2026-06-06): vault
research/EKB_Geoportal_WFS_Construction_Intel_Jun06.
Рич ПЗЗ-карточка (3 списка ВРИ + предельные параметры) отдельным дополнением через urbanCard
(см. research/EKB_Geoportal_PZZ_Content_API_Jun06), здесь НЕ реализуется (follow-up).
Рич ПЗЗ-карточка (3 списка ВРИ + предельные параметры) через urbanCard (#1067 C8):
searchByGeom urbanCard parse_zone_regulation (см. research/EKB_Geoportal_PZZ_Content_API_Jun06).
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
@ -35,6 +36,17 @@ logger = logging.getLogger(__name__)
EKB_GEOPORTAL_BASE = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai"
EKB_WFS_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/ows/3/portal/wfs"
# Reverse-eng JSON-API карточки территории (рич ПЗЗ-регламент). Менее стабилен чем WFS (OGC) —
# поэтому supplement к WFS, а не primary. Подтверждено live 2026-06-06 (vault
# research/EKB_Geoportal_PZZ_Content_API_Jun06): searchByGeom (точка → ключи объектов по слоям),
# urbanCard (ключи → полный градрегламент: 3 списка ВРИ 540н + предельные параметры).
EKB_SEARCH_BY_GEOM_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/spatialObject/searchByGeom"
EKB_URBAN_CARD_URL = f"{EKB_GEOPORTAL_BASE}/api/urbanCard/portal"
# layerKeys searchByGeom (внутренние числовые id портала, НЕ WFS typeNames):
# 17041 терзона ПЗЗ · 10019 участок ЕГРН · 16619 ЗОУИТ · 10079 кадастр.деление. Ответ:
# {layerKey: [{typeAlias, key, ...}]} — typeAlias UrbanTerZone/GknParcel/SpecialZone.
_SEARCH_LAYER_KEYS: tuple[int, ...] = (10079, 17041, 10019, 16619)
# Геометрическая колонка GeoServer-слоёв портала (таргет CQL-фильтра).
GEOM_COLUMN = "geoloc"
@ -73,6 +85,13 @@ def _http_get_json(url: str, params: dict[str, str], *, timeout: int = DEFAULT_T
return resp.json()
def _http_post_json(url: str, body: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT) -> Any:
"""POST JSON → JSON (для searchByGeom / urbanCard). Module-level для monkeypatch в тестах."""
resp = httpx.post(url, json=body, timeout=timeout)
resp.raise_for_status()
return resp.json()
# ── Typed response ───────────────────────────────────────────────────────────
@ -99,6 +118,64 @@ class EKBFeature:
)
@dataclass(frozen=True, slots=True)
class ZoneRegulation:
"""Градрегламент ПЗЗ-зоны из urbanCard (#1067 C8) — ИРД-content для #1059.
`zone_index` индекс зоны ('Ц-1'); `zone_full_name` полное имя
('Ц-1 Общественно-деловая зона городского центра'). ВРИ строки «код 540н + текст»
(напр. '2.6 Среднеэтажная жилая застройка'). `limit_params` предельные параметры
свободным текстом (min площадь ЗУ / max % застройки / этажность / отступы нуждается в
regex/LLM-экстракте в числовые поля, #1067 далее).
"""
zone_index: str | None
zone_full_name: str | None
main_vri: list[str]
conditional_vri: list[str]
auxiliary_vri: list[str]
limit_params: list[str]
def _vri_names(items: Any) -> list[str]:
"""Извлечь непустые .name из списка *TypesPermittedUse."""
out: list[str] = []
for it in items or []:
name = (it or {}).get("name")
if name:
out.append(str(name).strip())
return out
def parse_zone_regulation(card: dict[str, Any]) -> ZoneRegulation | None:
"""Парс ответа urbanCard → ZoneRegulation (берёт первый pzzList).
None если в карточке нет pzzList (участок без ПЗЗ-регламента). Shape подтверждён live
2026-06-06 (vault research/EKB_Geoportal_PZZ_Content_API_Jun06).
"""
pzz_list = (card or {}).get("pzzList") or []
if not pzz_list:
return None
pzz = pzz_list[0] or {}
full = pzz.get("urbanTerZone")
full_name = str(full).strip() if full else None
index = full_name.split()[0] if full_name else None
limit_params: list[str] = []
for limit in pzz.get("limitTypesPermittedUse") or []:
for par in (limit or {}).get("typePermittedUsePars") or []:
desc = (par or {}).get("otherDescription")
if desc and str(desc).strip():
limit_params.append(str(desc).strip())
return ZoneRegulation(
zone_index=index,
zone_full_name=full_name,
main_vri=_vri_names(pzz.get("mainTypesPermittedUse")),
conditional_vri=_vri_names(pzz.get("conditionTypesPermittedUse")),
auxiliary_vri=_vri_names(pzz.get("additionTypesPermittedUse")),
limit_params=limit_params,
)
# ── Client ───────────────────────────────────────────────────────────────────
@ -163,6 +240,54 @@ class EKBGeoportalClient:
idx = feats[0].properties.get("urban_index")
return str(idx) if idx is not None else None
# ── urbanCard (рич ПЗЗ-регламент, supplement к WFS) ─────────────────────
def _search_keys(self, lon: float, lat: float) -> dict[str, int]:
"""searchByGeom: точка → {typeAlias: key} (UrbanTerZone / GknParcel / SpecialZone)."""
body = {
"project": "portal",
"geoJsonGeometries": [json.dumps({"type": "Point", "coordinates": [lon, lat]})],
"layerKeys": list(_SEARCH_LAYER_KEYS),
"countByLayers": [],
}
data = _http_post_json(EKB_SEARCH_BY_GEOM_URL, body, timeout=self.timeout)
keys: dict[str, int] = {}
if isinstance(data, dict):
for objs in data.values():
for o in objs or []:
alias = (o or {}).get("typeAlias")
key = (o or {}).get("key")
if alias and key is not None:
keys[str(alias)] = int(key)
return keys
def _urban_card(self, terzone_key: int | None, parcel_key: int | None) -> dict[str, Any]:
"""urbanCard: ключи терзоны/участка → полная карточка территории (с pzzList)."""
body = {
"construction": [],
"adrCapitalBuilding": [],
"pzz": [terzone_key] if terzone_key is not None else [],
"parcel": [parcel_key] if parcel_key is not None else [],
"urbanOtherAreaPZZ": [],
"urbanProtAreaPZZ": [],
"urbanSanGapPZZ": [],
}
return _http_post_json(EKB_URBAN_CARD_URL, body, timeout=self.timeout)
def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation | None:
"""Полный градрегламент ПЗЗ-зоны в точке (#1059 content): индекс + 3 списка ВРИ +
предельные параметры. None если в точке нет терзоны/регламента.
Pipeline: searchByGeom (точка UrbanTerZone+GknParcel ключи) urbanCard parse.
Кэшировать вызывающему по zone_index (регламент идентичен для всех участков зоны).
"""
keys = self._search_keys(lon, lat)
terzone_key = keys.get("UrbanTerZone")
if terzone_key is None:
return None
card = self._urban_card(terzone_key, keys.get("GknParcel"))
return parse_zone_regulation(card)
__all__ = [
"EKB_GEOPORTAL_BASE",
@ -170,4 +295,6 @@ __all__ = [
"WFS_LAYERS",
"EKBFeature",
"EKBGeoportalClient",
"ZoneRegulation",
"parse_zone_regulation",
]

View file

@ -12,9 +12,12 @@ from typing import Any
import pytest
from app.services.scrapers.ekb_geoportal_client import (
EKB_SEARCH_BY_GEOM_URL,
EKB_URBAN_CARD_URL,
WFS_LAYERS,
EKBFeature,
EKBGeoportalClient,
parse_zone_regulation,
)
@ -118,3 +121,82 @@ def test_wfs_layers_catalog_has_key_layers() -> None:
for key in ("territorial_zone", "functional_zone", "krt", "ppt", "zouit"):
assert key in WFS_LAYERS
assert WFS_LAYERS[key].startswith("portal:")
# ── urbanCard (рич ПЗЗ-регламент) ────────────────────────────────────────────
def _card_fixture() -> dict[str, Any]:
"""urbanCard ответ (форма live-ответа Ц-1, vault recon)."""
return {
"pzzList": [
{
"urbanTerZone": "Ц-1 Общественно-деловая зона городского центра",
"mainTypesPermittedUse": [
{"name": "2.6 Среднеэтажная жилая застройка", "restriction": ""},
{"name": "4.1 Деловое управление", "restriction": ""},
],
"conditionTypesPermittedUse": [{"name": "4.9 Служебные гаражи"}],
"additionTypesPermittedUse": [{"name": "12.0.2 Благоустройство территории"}],
"limitTypesPermittedUse": [
{
"typePermittedUsePars": [
{"otherDescription": "минимальная площадь ЗУ - 1500 кв. м"},
{"otherDescription": ""},
]
}
],
}
]
}
def test_parse_zone_regulation() -> None:
"""parse_zone_regulation вытаскивает index + 3 списка ВРИ + предельные параметры."""
reg = parse_zone_regulation(_card_fixture())
assert reg is not None
assert reg.zone_index == "Ц-1"
assert reg.zone_full_name.startswith("Ц-1 Общественно-деловая")
assert reg.main_vri == ["2.6 Среднеэтажная жилая застройка", "4.1 Деловое управление"]
assert reg.conditional_vri == ["4.9 Служебные гаражи"]
assert reg.auxiliary_vri == ["12.0.2 Благоустройство территории"]
# пустой otherDescription отброшен → только непустой параметр
assert reg.limit_params == ["минимальная площадь ЗУ - 1500 кв. м"]
def test_parse_zone_regulation_empty() -> None:
"""Нет pzzList → None (участок без регламента)."""
assert parse_zone_regulation({"pzzList": []}) is None
assert parse_zone_regulation({}) is None
def test_zone_regulation_at_pipeline(monkeypatch: pytest.MonkeyPatch) -> None:
"""zone_regulation_at: searchByGeom → urbanCard → parse (мок по URL)."""
search_resp = {
"10019": [{"typeAlias": "GknParcel", "key": 111}],
"17041": [{"typeAlias": "UrbanTerZone", "key": 222}],
}
def fake_post(url: str, body: dict[str, Any], **kw: Any) -> dict[str, Any]:
if url == EKB_SEARCH_BY_GEOM_URL:
return search_resp
if url == EKB_URBAN_CARD_URL:
assert body["pzz"] == [222] # терзона-ключ прокинут
assert body["parcel"] == [111]
return _card_fixture()
raise AssertionError(f"unexpected url {url}")
monkeypatch.setattr("app.services.scrapers.ekb_geoportal_client._http_post_json", fake_post)
reg = EKBGeoportalClient().zone_regulation_at(60.6107, 56.8391)
assert reg is not None
assert reg.zone_index == "Ц-1"
assert len(reg.main_vri) == 2
def test_zone_regulation_at_none_without_terzone(monkeypatch: pytest.MonkeyPatch) -> None:
"""Нет UrbanTerZone в searchByGeom → None (urbanCard не дёргается)."""
monkeypatch.setattr(
"app.services.scrapers.ekb_geoportal_client._http_post_json",
lambda url, body, **kw: {"10019": [{"typeAlias": "GknParcel", "key": 1}]},
)
assert EKBGeoportalClient().zone_regulation_at(0.0, 0.0) is None