From 4542ecf6c9468705ce3a7efe8b82b73782aa014c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 6 Jun 2026 22:18:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(sf):=20zone=5Fregulation=5Fcache=20?= =?UTF-8?q?=E2=80=94=20=D0=9F=D0=97=D0=97-=D1=80=D0=B5=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=20=D0=BF=D0=BE=20zone=5Findex=20+=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BA=D1=82=20(#1067=20C8b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/site_finder/zone_regulation.py | 283 ++++++++++++++++++ .../services/test_zone_regulation_extract.py | 135 +++++++++ data/sql/133_zone_regulation_cache.sql | 93 ++++++ 3 files changed, 511 insertions(+) create mode 100644 backend/app/services/site_finder/zone_regulation.py create mode 100644 backend/tests/services/test_zone_regulation_extract.py create mode 100644 data/sql/133_zone_regulation_cache.sql diff --git a/backend/app/services/site_finder/zone_regulation.py b/backend/app/services/site_finder/zone_regulation.py new file mode 100644 index 00000000..9c00238c --- /dev/null +++ b/backend/app/services/site_finder/zone_regulation.py @@ -0,0 +1,283 @@ +"""Кэш ПЗЗ-градрегламента по индексу зоны + числовой экстракт предельных параметров (#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", +] diff --git a/backend/tests/services/test_zone_regulation_extract.py b/backend/tests/services/test_zone_regulation_extract.py new file mode 100644 index 00000000..684a778d --- /dev/null +++ b/backend/tests/services/test_zone_regulation_extract.py @@ -0,0 +1,135 @@ +"""Тесты числового экстракта предельных параметров ПЗЗ (#1067 C8b). + +Строки limit_params — РЕАЛЬНЫЕ otherDescription с живого ЕКБ-геопортала urbanCard (probe +2026-06-06: зоны Ц-1, ЦС-1, ЦС-6). Проверяют что regex берёт числа из «– NUN кв.м/%/этаж», +игнорит «не подлежат установлению», тянет per-ВРИ повторы и десятичную запятую. +""" + +from __future__ import annotations + +from app.services.scrapers.ekb_geoportal_client import ZoneRegulation +from app.services.site_finder.zone_regulation import ( + _headline_scalars, + parse_limit_params, + upsert_zone_regulation, +) + +# Реальный otherDescription зоны Ц-1 (среднеэтажная 1500 / многоэтажная 3000 + прочее). +_C1 = ( + "минимальная площадь земельного участка - 1500 кв. м;\n" + "минимальные отступы от границ земельных участков ... не подлежат установлению;\n" + "предельное количество этажей или предельная высота зданий, строений, сооружений не " + "подлежат установлению;\n" + "максимальный процент застройки в границах земельного участка, определяемый как отношение " + "суммарной площади земельного участка, которая может быть застроена, ко всей площади " + "земельного участка – 100%; Иные предельные параметры:\n" + "коэффициент строительного использования земельного участка – 4;\n" + "процент застройки подземного пространства – 100%;\n" + "минимальная площадь земельного участка - 3000 кв. м;\n" + "максимальный процент застройки в границах земельного участка ... для многоэтажной жилой " + "застройки – 100%;\n" +) + +# Зона ЦС-1: КСИ 2,4 / 2,5 / 2 (десятичная запятая), этажность 5, застройка 80%, площадь 5000. +_CS1 = ( + "максимальный процент застройки в границах земельного участка, определяемый как отношение " + "суммарной площади земельного участка ... ко всей площади земельного участка – 80%;\n" + "коэффициент строительного использования земельного участка (за исключением ...) – 2,4;\n" + "коэффициент строительного использования земельного участка (только в отношении ...) - 2,5;\n" + "коэффициент строительного использования земельного участка (только в отношении ...) – 2;\n" + 'предельная этажность (только в отношении ... "гостиничное обслуживание") – 5 этажей;\n' + "максимальная общая площадь ... – 5 000 кв.м;\n" +) + +# Зона ЦС-6: всё «не подлежат установлению» — числовой экстракт пуст. +_CS6 = ( + "предельные (минимальные и (или) максимальные) размеры земельных участков, в том числе их " + "площадь, не подлежат установлению;\n" + "максимальный процент застройки в границах земельного участка ... не подлежит установлению;\n" + "Иные предельные параметры разрешенного строительства ... не подлежат установлению\n" +) + + +def test_c1_min_area_per_vri() -> None: + """Обе min-площади (среднеэтажная 1500 + многоэтажная 3000) тянутся как список.""" + p = parse_limit_params([_C1]) + assert p["min_parcel_area_m2"] == [1500.0, 3000.0] + + +def test_c1_far_and_building_and_underground() -> None: + p = parse_limit_params([_C1]) + assert p["max_far"] == [4.0] + assert p["max_building_pct"] == [100.0, 100.0] # per-ВРИ повтор + assert p["underground_pct"] == [100.0] + + +def test_c1_no_floors_or_height_when_not_established() -> None: + """«не подлежат установлению» для этажей/высоты → ключи отсутствуют.""" + p = parse_limit_params([_C1]) + assert "max_floors" not in p + assert "max_height_m" not in p + + +def test_cs1_decimal_comma_far() -> None: + """КСИ с десятичной запятой (2,4 / 2,5 / 2) парсятся в float.""" + p = parse_limit_params([_CS1]) + assert p["max_far"] == [2.4, 2.5, 2.0] + + +def test_cs1_floors_and_thousand_separated_area() -> None: + p = parse_limit_params([_CS1]) + assert p["max_floors"] == [5.0] + assert p["max_building_pct"] == [80.0] + + +def test_cs6_all_not_established_is_empty() -> None: + assert parse_limit_params([_CS6]) == {} + + +def test_empty_inputs() -> None: + assert parse_limit_params(None) == {} + assert parse_limit_params([]) == {} + assert parse_limit_params(["", None]) == {} # type: ignore[list-item] + + +def test_headline_scalars_most_permissive() -> None: + """min_parcel_area = наименьший минимум; прочие = max; max_floors = int.""" + p = parse_limit_params([_C1, _CS1]) + s = _headline_scalars(p) + assert s["min_parcel_area_m2"] == 1500.0 # min из [1500, 3000] + assert s["max_far"] == 4.0 # max из [4, 2.4, 2.5, 2] + assert s["max_building_pct"] == 100.0 + assert s["max_floors"] == 5 + assert isinstance(s["max_floors"], int) + assert s["max_height_m"] is None # нет ни одного → None + + +def test_headline_scalars_empty() -> None: + s = _headline_scalars({}) + assert all(v is None for v in s.values()) + assert set(s) == { + "max_far", + "max_building_pct", + "underground_pct", + "min_parcel_area_m2", + "max_floors", + "max_height_m", + } + + +def test_upsert_skips_when_no_zone_index() -> None: + """Регламент без zone_index не пишется (None), БД не трогается.""" + reg = ZoneRegulation( + zone_index=None, + zone_full_name=None, + main_vri=[], + conditional_vri=[], + auxiliary_vri=[], + limit_params=[], + ) + + class _Boom: + def begin_nested(self): # pragma: no cover - не должен вызываться + raise AssertionError("db не должна трогаться при отсутствии zone_index") + + assert upsert_zone_regulation(_Boom(), reg) is None # type: ignore[arg-type] diff --git a/data/sql/133_zone_regulation_cache.sql b/data/sql/133_zone_regulation_cache.sql new file mode 100644 index 00000000..1cf2ed01 --- /dev/null +++ b/data/sql/133_zone_regulation_cache.sql @@ -0,0 +1,93 @@ +-- 133_zone_regulation_cache.sql +-- #1067 (Site Finder v2 / ИРД-content «GG-форсайт», C8b) — кэш ПЗЗ-регламента по индексу зоны. +-- +-- Регламент ПЗЗ идентичен для ВСЕХ участков одной территориальной зоны (Ц-1, Ж-2, ...), а зон +-- на город ~100. Поэтому резолвить ВРИ+предельные параметры через ЕКБ-геопортал urbanCard на +-- каждый участок — расточительно (reverse-eng API без SLA, см. ekb_geoportal_client.py). Кэшируем +-- ОДИН раз на (city, zone_index): 3 списка ВРИ (540н) + сырой текст предельных параметров + +-- числовой экстракт (regex) ключевых параметров застройки для capacity-оценки. +-- +-- Что отдаёт urbanCard (live-verified 2026-06-06, vault research/EKB_Geoportal_PZZ_Content_API_Jun06): +-- pzzList[0]: urbanTerZone (index+имя) · main/condition/addition TypesPermittedUse (ВРИ) · +-- limitTypesPermittedUse[].typePermittedUsePars[].otherDescription — предельные параметры +-- СВОБОДНЫМ ТЕКСТОМ (min площадь ЗУ / max % застройки / КСИ / подземная застройка / этажность), +-- часто «не подлежат установлению». Парсятся сервисом zone_regulation.py (regex) → params_numeric. +-- +-- Зерно (grain): одна строка = один индекс зоны в городе. Дедуп-ключ — (city, zone_index). +-- re-fetch UPSERT-ит (latest wins): регламент меняется только при правке ПЗЗ (редко) → +-- fetched_at = freshness, refresh-воркер дёргает по возрасту. +-- +-- Связь с ird_overlays (м.132): ird_overlays.zone_index (когда резолвлен) JOIN-ится сюда за +-- полным регламентом зоны участка. content в ird_overlays НЕ дублируется — single source тут. +-- +-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN). Idempotent: +-- CREATE TABLE/INDEX IF NOT EXISTS + guarded DO для UNIQUE (constraint нельзя добавить дважды, +-- прецедент м.40/110/125/132). НИКАКИХ строк не вставляется (данные — из сервиса C8b). + +BEGIN; + +-- ── 1. Таблица zone_regulation_cache ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS zone_regulation_cache ( + id BIGSERIAL PRIMARY KEY, + city TEXT NOT NULL DEFAULT 'ekb', -- мульти-город forward-compat; пока только ЕКБ + zone_index TEXT NOT NULL, -- индекс ПЗЗ-зоны ('Ц-1', 'Ж-2', 'ЦС-1') + zone_full_name TEXT, -- 'Ц-1 Общественно-деловая зона городского центра' + -- ── ВРИ (списки строк «код 540н + текст», JSONB-массивы) ── + main_vri JSONB NOT NULL DEFAULT '[]'::jsonb, -- основные ВРИ + conditional_vri JSONB NOT NULL DEFAULT '[]'::jsonb, -- условно-разрешённые ВРИ + auxiliary_vri JSONB NOT NULL DEFAULT '[]'::jsonb, -- вспомогательные ВРИ + -- ── предельные параметры ── + limit_params_raw JSONB NOT NULL DEFAULT '[]'::jsonb, -- сырые otherDescription-строки (forward-compat/LLM) + params_numeric JSONB NOT NULL DEFAULT '{}'::jsonb, -- структурный экстракт: {param: [values...]} (regex, C8b) + -- ── headline-скаляры для capacity (most-permissive из найденных; NULL = «не подлежат установлению»/нет) ── + max_far NUMERIC, -- коэф. строит. использования ЗУ (КСИ), max найденный + max_building_pct NUMERIC, -- макс. процент застройки, max найденный (%) + underground_pct NUMERIC, -- процент застройки подземного пространства, max (%) + min_parcel_area_m2 NUMERIC, -- мин. площадь ЗУ, наименьший из найденных минимумов (кв. м) + max_floors INTEGER, -- предельная этажность, max найденный + max_height_m NUMERIC, -- предельная высота, max найденный (м) + source TEXT NOT NULL DEFAULT 'ekb_geoportal', -- источник регламента + fetched_at timestamptz NOT NULL DEFAULT now(), -- когда резолвили (freshness для refresh) + -- city/zone_index — осмысленные только непустыми; ловим мусор громко на write. + CONSTRAINT zone_regulation_cache_zone_index_chk CHECK (length(zone_index) > 0), + CONSTRAINT zone_regulation_cache_city_chk CHECK (length(city) > 0) +); + +COMMENT ON TABLE zone_regulation_cache IS + 'Кэш ПЗЗ-градрегламента по индексу зоны (#1067 C8b, GG-форсайт). 1 строка = 1 (city, ' + 'zone_index). 3 списка ВРИ (540н) + сырой текст предельных параметров + числовой экстракт ' + '(params_numeric/headline-скаляры, regex). Резолв из ЕКБ-геопортала urbanCard (zone_regulation.py). ' + 'UPSERT по uq_zone_regulation_cache (city, zone_index); latest wins. ird_overlays.zone_index JOIN-ится сюда.'; +COMMENT ON COLUMN zone_regulation_cache.params_numeric IS + 'Числовой экстракт предельных параметров (regex из limit_params_raw): {max_far:[..], ' + 'max_building_pct:[..], underground_pct:[..], min_parcel_area_m2:[..], max_floors:[..], ' + 'max_height_m:[..]}. Списки т.к. параметр повторяется per-ВРИ. headline-скаляры — агрегат.'; +COMMENT ON COLUMN zone_regulation_cache.max_far IS + 'Коэф. строит. использования ЗУ (КСИ) — max найденный (capacity best-case). NULL = не установлен'; +COMMENT ON COLUMN zone_regulation_cache.min_parcel_area_m2 IS + 'Мин. площадь ЗУ — НАИМЕНЬШИЙ из найденных минимумов (самый разрешительный). NULL = не установлен'; + +-- ── 2. Дедуп/UPSERT-ключ (city, zone_index) ──────────────────────────────────── +-- Цель ON CONFLICT для сервиса C8b: re-fetch обновляет регламент той же зоны, не плодя дубли. +-- Guard по pg_constraint → idempotent re-apply (прецедент м.125/132). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'uq_zone_regulation_cache' + ) THEN + ALTER TABLE zone_regulation_cache + ADD CONSTRAINT uq_zone_regulation_cache + UNIQUE (city, zone_index); + END IF; +END +$$; + +-- ── 3. Индексы ───────────────────────────────────────────────────────────────── +-- Главный read-path: lookup регламента по индексу зоны (UNIQUE уже покрывает (city, zone_index), +-- но частый запрос — по zone_index без city). Очередь refresh — по возрасту (fetched_at). +CREATE INDEX IF NOT EXISTS idx_zone_regulation_cache_zone_index + ON zone_regulation_cache (zone_index); +CREATE INDEX IF NOT EXISTS idx_zone_regulation_cache_fetched_at + ON zone_regulation_cache (fetched_at); + +COMMIT;