feat(sf): zone_regulation_cache — ПЗЗ-регламент по zone_index + числовой экстракт (#1067 C8b)
This commit is contained in:
parent
e792bed325
commit
4542ecf6c9
3 changed files with 511 additions and 0 deletions
283
backend/app/services/site_finder/zone_regulation.py
Normal file
283
backend/app/services/site_finder/zone_regulation.py
Normal 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",
|
||||
]
|
||||
135
backend/tests/services/test_zone_regulation_extract.py
Normal file
135
backend/tests/services/test_zone_regulation_extract.py
Normal 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]
|
||||
93
data/sql/133_zone_regulation_cache.sql
Normal file
93
data/sql/133_zone_regulation_cache.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue