"""Кэш ПЗЗ-градрегламента по индексу зоны + числовой экстракт предельных параметров (#1067 C8b). Регламент ПЗЗ идентичен для всех участков одной терзоны, а зон на город ~100 → резолвим из ЕКБ-геопортала urbanCard ОДИН раз на (city, zone_index) и кэшируем в ``zone_regulation_cache`` (м.133). Предельные параметры urbanCard отдаёт СВОБОДНЫМ ТЕКСТОМ (``otherDescription``) — тут же их числовой экстракт (regex) в ключевые поля застройки для capacity-оценки. Слои: * :func:`parse_limit_params` — чистый экстрактор (regex, без IO; основной тест-таргет). * :func:`upsert_zone_regulation` / :func:`get_cached_zone_regulation` — запись/чтение кэша. * :func:`get_or_fetch_zone_regulation` — get-or-fetch (cache miss → urbanCard → upsert). Текст предельных параметров (live-verified 2026-06-06, vault research/EKB_Geoportal_PZZ_Content_API_Jun06) — пример (зона Ц-1): 'минимальная площадь земельного участка - 1500 кв. м;\\n максимальный процент застройки ... – 100%;\\n коэффициент строительного использования земельного участка – 4;\\n процент застройки подземного пространства – 100%;\\n ...' Каждый параметр часто повторяется per-ВРИ (среднеэтажная 1500 / многоэтажная 3000) и сплошь встречается «не подлежат установлению» (числа нет → НЕ матчим). Поэтому params_numeric хранит СПИСКИ значений, а headline-скаляры — most-permissive агрегат (capacity best-case). Graceful: при отсутствии таблицы/ошибке БД чтение возвращает None, analyze не падает (wiring D9b). """ from __future__ import annotations import json import logging import re from typing import Any from sqlalchemy import text from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.orm import Session from app.services.scrapers.ekb_geoportal_client import ( EKBGeoportalClient, ZoneRegulation, ) logger = logging.getLogger(__name__) # ── Числовой экстракт предельных параметров (regex) ────────────────────────── # Числовой токен: тысячные разделены пробелом/nbsp ('50 000'), десятичная — запятая ('2,4'). # Сначала grouped-тысячи, иначе простое число. `.` по умолчанию НЕ переходит \n — параметры # разделены \n, поэтому non-greedy `[^\n]*?` не перепрыгнет на число следующего параметра. _NUM = r"(\d{1,3}(?:[  ]\d{3})+(?:[.,]\d+)?|\d+(?:[.,]\d+)?)" # Тире в реальном тексте варьируется: дефис, en-dash, em-dash. _DASH = r"[-–—]" # мин. площадь ЗУ — 'минимальная площадь земельного участка - 1500 кв. м' _RE_MIN_AREA = re.compile( r"минимальн\w*\s+площад\w*\s+земельн\w*\s+участка\s*" + _DASH + r"\s*" + _NUM + r"\s*кв", re.IGNORECASE, ) # макс. % застройки — 'максимальный процент застройки ... – 100%' _RE_BUILD_PCT = re.compile( r"максимальн\w*\s+процент\s+застройки[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*%", re.IGNORECASE, ) # КСИ — 'коэффициент строительного использования земельного участка ... – 4' / '– 2,4' _RE_FAR = re.compile( r"коэффициент\s+строительного\s+использования\s+земельн\w*\s+участка[^\n]*?" + _DASH + r"\s*" + _NUM, re.IGNORECASE, ) # подземная застройка — 'процент застройки подземного пространства – 100%' _RE_UNDERGROUND = re.compile( r"процент\s+застройки\s+подземного\s+пространства\s*" + _DASH + r"\s*" + _NUM + r"\s*%", re.IGNORECASE, ) # этажность — 'предельная этажность ... – 5 этажей' / 'предельное количество этажей ... – 5' _RE_FLOORS = re.compile( r"(?:предельн\w*\s+этажност\w*|предельное\s+количество\s+этажей)[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*этаж", re.IGNORECASE, ) # высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО) _RE_HEIGHT = re.compile( r"предельн\w*\s+высот\w*[^\n]*?" + _DASH + r"\s*" + _NUM + r"\s*м", re.IGNORECASE, ) # param-ключ → (compiled regex, агрегатор headline-скаляра по списку значений). # min_parcel_area — НАИМЕНЬШИЙ минимум (самый разрешительный); прочие — max (capacity best-case). _PARAM_SPECS: tuple[tuple[str, re.Pattern[str], Any], ...] = ( ("max_far", _RE_FAR, max), ("max_building_pct", _RE_BUILD_PCT, max), ("underground_pct", _RE_UNDERGROUND, max), ("min_parcel_area_m2", _RE_MIN_AREA, min), ("max_floors", _RE_FLOORS, max), ("max_height_m", _RE_HEIGHT, max), ) def _to_number(raw: str) -> float: """Нормализовать захваченный токен в float: убрать пробелы/nbsp тысяч, запятая → точка.""" s = raw.replace(" ", "").replace(" ", "").replace(",", ".") return float(s) def parse_limit_params(texts: list[str] | None) -> dict[str, list[float]]: """Числовой экстракт предельных параметров из сырых ``otherDescription``-строк. Args: texts: список свободно-текстовых блоков предельных параметров (limit_params). Returns: ``{param_key: [значения...]}`` только для параметров, где найдено ≥1 число. Значения в порядке появления, дубликаты сохраняются (per-ВРИ повторы осмысленны). Параметры: ``max_far`` (КСИ), ``max_building_pct``, ``underground_pct``, ``min_parcel_area_m2``, ``max_floors``, ``max_height_m``. """ out: dict[str, list[float]] = {} blob = "\n".join(t for t in (texts or []) if t) if not blob: return out for key, rx, _agg in _PARAM_SPECS: vals = [_to_number(m) for m in rx.findall(blob)] if vals: out[key] = vals return out def _headline_scalars(params_numeric: dict[str, list[float]]) -> dict[str, float | int | None]: """Агрегировать списки значений в headline-скаляры (most-permissive для capacity).""" scalars: dict[str, float | int | None] = {} for key, _rx, agg in _PARAM_SPECS: vals = params_numeric.get(key) or [] if not vals: scalars[key] = None continue v = agg(vals) scalars[key] = int(v) if key == "max_floors" else float(v) return scalars # ── Кэш-таблица zone_regulation_cache (м.133) ──────────────────────────────── _UPSERT_SQL = text( """ INSERT INTO zone_regulation_cache ( city, zone_index, zone_full_name, main_vri, conditional_vri, auxiliary_vri, limit_params_raw, params_numeric, max_far, max_building_pct, underground_pct, min_parcel_area_m2, max_floors, max_height_m, source, fetched_at ) VALUES ( :city, :zone_index, :zone_full_name, CAST(:main_vri AS jsonb), CAST(:conditional_vri AS jsonb), CAST(:auxiliary_vri AS jsonb), CAST(:limit_params_raw AS jsonb), CAST(:params_numeric AS jsonb), :max_far, :max_building_pct, :underground_pct, :min_parcel_area_m2, :max_floors, :max_height_m, :source, now() ) ON CONFLICT (city, zone_index) DO UPDATE SET zone_full_name = EXCLUDED.zone_full_name, main_vri = EXCLUDED.main_vri, conditional_vri = EXCLUDED.conditional_vri, auxiliary_vri = EXCLUDED.auxiliary_vri, limit_params_raw = EXCLUDED.limit_params_raw, params_numeric = EXCLUDED.params_numeric, max_far = EXCLUDED.max_far, max_building_pct = EXCLUDED.max_building_pct, underground_pct = EXCLUDED.underground_pct, min_parcel_area_m2 = EXCLUDED.min_parcel_area_m2, max_floors = EXCLUDED.max_floors, max_height_m = EXCLUDED.max_height_m, source = EXCLUDED.source, fetched_at = now() """ ) _SELECT_SQL = text( """ SELECT city, zone_index, zone_full_name, main_vri, conditional_vri, auxiliary_vri, limit_params_raw, params_numeric, max_far, max_building_pct, underground_pct, min_parcel_area_m2, max_floors, max_height_m, source, fetched_at FROM zone_regulation_cache WHERE city = :city AND zone_index = :zone_index """ ) def upsert_zone_regulation( db: Session, reg: ZoneRegulation, *, city: str = "ekb", source: str = "ekb_geoportal" ) -> dict[str, list[float]] | None: """Записать регламент зоны в кэш (UPSERT по (city, zone_index)) + числовой экстракт. Returns: params_numeric (числовой экстракт) при успехе; None если у регламента нет zone_index или таблица недоступна (graceful). """ if not reg.zone_index: return None params_numeric = parse_limit_params(reg.limit_params) scalars = _headline_scalars(params_numeric) payload = { "city": city, "zone_index": reg.zone_index, "zone_full_name": reg.zone_full_name, "main_vri": json.dumps(reg.main_vri, ensure_ascii=False), "conditional_vri": json.dumps(reg.conditional_vri, ensure_ascii=False), "auxiliary_vri": json.dumps(reg.auxiliary_vri, ensure_ascii=False), "limit_params_raw": json.dumps(reg.limit_params, ensure_ascii=False), "params_numeric": json.dumps(params_numeric, ensure_ascii=False), "source": source, **scalars, } try: with db.begin_nested(): db.execute(_UPSERT_SQL, payload) except (OperationalError, ProgrammingError) as exc: logger.warning("upsert_zone_regulation: zone_regulation_cache недоступна, skip: %s", exc) return None return params_numeric def get_cached_zone_regulation( db: Session, zone_index: str | None, *, city: str = "ekb" ) -> dict[str, Any] | None: """Прочитать кэшированный регламент зоны. None при отсутствии/недоступности таблицы.""" if not zone_index: return None try: row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first() except (OperationalError, ProgrammingError) as exc: logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc) return None return dict(row) if row else None def get_or_fetch_zone_regulation( db: Session, lon: float, lat: float, *, city: str = "ekb", client: EKBGeoportalClient | None = None, ) -> dict[str, Any] | None: """Get-or-fetch регламент зоны в точке: кэш → (miss) urbanCard → upsert → кэш. Сначала индекс зоны по точке (WFS, дёшево); если он уже в кэше — отдаём кэш без urbanCard. Иначе полный резолв через urbanCard + запись. None если в точке нет терзоны/регламента. """ client = client or EKBGeoportalClient() try: zone_index = client.zone_index_at(lon, lat) except Exception as exc: logger.warning("get_or_fetch_zone_regulation: zone_index_at failed: %s", exc) return None if zone_index: cached = get_cached_zone_regulation(db, zone_index, city=city) if cached is not None: return cached try: reg = client.zone_regulation_at(lon, lat) except Exception as exc: logger.warning("get_or_fetch_zone_regulation: zone_regulation_at failed: %s", exc) return None if reg is None or not reg.zone_index: return None upsert_zone_regulation(db, reg, city=city) db.commit() return get_cached_zone_regulation(db, reg.zone_index, city=city) __all__ = [ "get_cached_zone_regulation", "get_or_fetch_zone_regulation", "parse_limit_params", "upsert_zone_regulation", ]