Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543). Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py. Полностью исправлено (82). Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650. Closes #1560 Closes #1561 Closes #1562 Closes #1563 Closes #1564 Closes #1565 Closes #1566 Closes #1567 Closes #1570 Closes #1571 Closes #1572 Closes #1573 Closes #1574 Closes #1576 Closes #1577 Closes #1578 Closes #1579 Closes #1580 Closes #1581 Closes #1582 Closes #1583 Closes #1584 Closes #1585 Closes #1586 Closes #1587 Closes #1588 Closes #1589 Closes #1591 Closes #1592 Closes #1594 Closes #1595 Closes #1596 Closes #1597 Closes #1598 Closes #1599 Closes #1600 Closes #1601 Closes #1602 Closes #1603 Closes #1604 Closes #1605 Closes #1607 Closes #1608 Closes #1610 Closes #1611 Closes #1612 Closes #1613 Closes #1614 Closes #1615 Closes #1616 Closes #1618 Closes #1619 Closes #1620 Closes #1621 Closes #1622 Closes #1623 Closes #1624 Closes #1625 Closes #1626 Closes #1627 Closes #1628 Closes #1629 Closes #1630 Closes #1631 Closes #1632 Closes #1634 Closes #1636 Closes #1639 Closes #1641 Closes #1643 Closes #1644 Closes #1645 Closes #1646 Closes #1647 Closes #1648 Closes #1649 Closes #1651 Closes #1652 Closes #1653 Closes #1654 Closes #1655 Closes #1656
283 lines
13 KiB
Python
283 lines
13 KiB
Python
"""Кэш ПЗЗ-градрегламента по индексу зоны + числовой экстракт предельных параметров (#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*этаж\w*)?",
|
||
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",
|
||
]
|