feat(sf): zone_regulation_cache — ПЗЗ-регламент по zone_index + числовой экстракт (#1099)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

Миграция 133_zone_regulation_cache.sql + zone_regulation.py: кэш ПЗЗ-регламента по (city, zone_index) — 3 списка ВРИ + сырой текст предельных параметров + regex-экстракт числовых (max_far/застройка/этажность/площадь). get-or-fetch через urbanCard. Резолв один раз на зону (~100/город). Self-contained, wiring в D9b. 10 тестов на реальных geoportal-строках.

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
lekss361 2026-06-06 19:31:28 +00:00 committed by bot-reviewer
parent ea5c1e29f6
commit e5b12d579a
3 changed files with 511 additions and 0 deletions

View file

@ -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",
]

View file

@ -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]

View file

@ -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;