135 lines
6.2 KiB
Python
135 lines
6.2 KiB
Python
"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-66).
|
||
|
||
Заменяет TODO-прокси quarter-prefix из analyze_parcel (#105 Phase 5): после того как
|
||
Phase 3 (geocoding → geom) наполнила `gisogd_permits.geom`, соседние РНС/РВЭ ищутся
|
||
точным `ST_DWithin(..., radius_m)` по геометрии, а не грубым совпадением префикса
|
||
кадастрового квартала. Старый quarter-prefix путь (`ekburg_construction_permits` →
|
||
`recent_permits_in_quarter` / `permits_summary`) НЕ трогается — он остаётся параллельно
|
||
как отдельный, более богатый полями источник; этот модуль лишь добавляет второй,
|
||
геометрически честный ключ payload.
|
||
|
||
`gisogd_permits`: id / doc_group ('RS' — разрешение на строительство, 'RV' — ввод в
|
||
эксплуатацию) / doc_num / doc_name / date_doc / date_reg / approved_organization /
|
||
cad_nums TEXT[] / geom geometry(MultiPolygon, 4326), GIST-индекс на geom.
|
||
|
||
PURE-по-данным (никаких выдуманных чисел): пустой радиус — честный ноль
|
||
(total_count=0, items=[]), НЕ ошибка и НЕ None. Вызывается из синхронного analyze_parcel
|
||
с той же non-fatal семантикой.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Максимум записей в списке items. Счётчики (total_count/rs_count/rv_count/
|
||
# nearest_distance_m) считаются по ПОЛНОЙ выборке радиуса — БЕЗ LIMIT в SQL, чтобы не было
|
||
# тихого капа (проектная конвенция «без тихих капов»: тот же паттерн чинили в дедупе
|
||
# объявлений и фрешнес-мониторе). LIMIT капает лишь выдачу items → items_truncated=True.
|
||
_ITEMS_LIMIT = 30
|
||
|
||
# Радиус-запрос по образцу POI-в-радиусе (osm_poi_ekb) из analyze_parcel: ST_DWithin по
|
||
# geography-центроиду участка. `::geography` приклеено к `)`, НЕ к bind-name → psycopg v3
|
||
# его не съедает (backend.md: исключение из :name::type-запрета). БЕЗ LIMIT: агрегаты честны
|
||
# по всей выборке; в радиусе 500м GIST-индекс делает выборку дешёвой, тысяч строк не будет.
|
||
_PERMITS_NEARBY_SQL = text("""
|
||
SELECT
|
||
doc_group,
|
||
doc_name,
|
||
doc_num,
|
||
date_doc,
|
||
date_reg,
|
||
approved_organization,
|
||
ST_Distance(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM gisogd_permits
|
||
WHERE geom IS NOT NULL
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
:radius_m
|
||
)
|
||
ORDER BY distance_m ASC
|
||
""")
|
||
|
||
|
||
def _empty_result(radius_m: int) -> dict[str, Any]:
|
||
"""Честный нулевой агрегат: 0 разрешений в радиусе (НЕ ошибка, НЕ None)."""
|
||
return {
|
||
"radius_m": radius_m,
|
||
"total_count": 0,
|
||
"rs_count": 0,
|
||
"rv_count": 0,
|
||
"nearest_distance_m": None,
|
||
"items": [],
|
||
"items_truncated": False,
|
||
"source": "gisogd66",
|
||
}
|
||
|
||
|
||
def get_permits_nearby(db: Session, geom_wkt: str, radius_m: int = 500) -> dict[str, Any]:
|
||
"""РНС/РВЭ из ГИСОГД-66 в радиусе `radius_m` метров от участка (точный ST_DWithin).
|
||
|
||
Args:
|
||
db: активная сессия SQLAlchemy.
|
||
geom_wkt: WKT-геометрия анализируемого участка (та же переменная, что для POI).
|
||
radius_m: радиус поиска в метрах (по умолчанию 500).
|
||
|
||
Returns:
|
||
Агрегат с ЧЕСТНЫМИ счётчиками (см. `_empty_result` для пустой формы):
|
||
radius_m, total_count / rs_count ('RS') / rv_count ('RV') — по ВСЕЙ выборке радиуса
|
||
(без тихого капа), nearest_distance_m (round 1, None если пусто),
|
||
items (≤30 ближайших), items_truncated (True если total_count > len(items)),
|
||
source="gisogd66". Пустой радиус → честный ноль.
|
||
"""
|
||
rows = [
|
||
dict(r)
|
||
for r in db.execute(
|
||
_PERMITS_NEARBY_SQL,
|
||
{"wkt": geom_wkt, "radius_m": radius_m},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
]
|
||
|
||
if not rows:
|
||
return _empty_result(radius_m)
|
||
|
||
# Счётчики — по ПОЛНОЙ выборке (все rows), список items — только первые _ITEMS_LIMIT.
|
||
rs_count = sum(1 for r in rows if r["doc_group"] == "RS")
|
||
rv_count = sum(1 for r in rows if r["doc_group"] == "RV")
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for r in rows[:_ITEMS_LIMIT]:
|
||
date_doc = r["date_doc"]
|
||
distance_m = r["distance_m"]
|
||
items.append(
|
||
{
|
||
"doc_group": r["doc_group"],
|
||
"doc_name": r["doc_name"],
|
||
"doc_num": r["doc_num"],
|
||
"date_doc": date_doc.isoformat() if date_doc is not None else None,
|
||
"approved_organization": r["approved_organization"],
|
||
"distance_m": round(distance_m, 1) if distance_m is not None else None,
|
||
}
|
||
)
|
||
|
||
nearest = items[0]["distance_m"] if items else None
|
||
total_count = len(rows)
|
||
return {
|
||
"radius_m": radius_m,
|
||
"total_count": total_count,
|
||
"rs_count": rs_count,
|
||
"rv_count": rv_count,
|
||
"nearest_distance_m": nearest,
|
||
"items": items,
|
||
"items_truncated": total_count > len(items),
|
||
"source": "gisogd66",
|
||
}
|