Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
290 lines
14 KiB
Python
290 lines
14 KiB
Python
"""Per-building помещения ЕГРН + машино-места + parking_ratio (#96, MVP on-demand).
|
||
|
||
Тонкая обёртка над уже-готовым foundation `NSPDBulkClient.list_objects_in_building`
|
||
(#168 Q3-deferred). Связывает кадастровый номер здания → objdoc_id → NSPD
|
||
tab-group `objectsList` → counts помещений/машино-мест → `parking_ratio`.
|
||
|
||
Зачем именно так (#96 scope decision):
|
||
- Полный план #96 (cad_premises/cad_parking таблицы, per-flat кадастровая
|
||
стоимость, bulk_parking_for_district, миграция) упирается в незакрытый
|
||
data-foundation gap: здания-конкуренты в analyze живут в координатах ДОМ.РФ
|
||
(obj_id + lat/lon), а НЕ имеют cad_num/objdoc_id НСПД. Массовый backfill
|
||
помещений по всем зданиям ЕКБ (38k × tab-group) — тяжёлый rate-limited
|
||
отдельный таск. Поэтому MVP: on-demand по конкретному cad_num здания, без
|
||
схемы и без bulk-сбора.
|
||
|
||
Поток (2 HTTP-запроса на здание):
|
||
1. NSPDClient.search_by_cad(building_cad) → options.objdoc_id (sync urllib)
|
||
2. NSPDBulkClient.list_objects_in_building(objdoc_id) → ObjectsListing (async)
|
||
|
||
objdoc_id из НСПД search приходит в camelCase `objdocId` — парсится через
|
||
NSPDOptions AliasChoices (см. schemas/nspd_bulk.py). Verified live на
|
||
66:41:0106036:183 (МКД, 232 помещения, 0 машино-мест).
|
||
|
||
Вызывается из синхронного кода (FastAPI threadpool handler / Celery task) —
|
||
async-часть изолирована в asyncio.run() внутри функции (паттерн poi_loader.py /
|
||
pzz_loader.py). НЕ блокирует основной event loop, т.к. analyze_parcel — `def`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from dataclasses import dataclass
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.schemas.nspd_bulk import ObjectsListing
|
||
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
||
from app.services.scrapers.nspd_client import NSPDClient, NspdLiteError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# thematic_id=5 — НСПД search-фильтр «Здания» (см. NSPDClient.search_by_cad
|
||
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
||
_THEMATIC_BUILDING = 5
|
||
|
||
# #96 wiring: дефолтный радиус geom-match domrf↔cad_buildings (метры). Подобран по
|
||
# прод-замеру (1543 domrf latest-snapshot vs 38016 cad_buildings): nearest-footprint
|
||
# медиана 56м, within-50м покрывает 47%, within-100м 65%. 50м — консервативный
|
||
# дефолт: domrf lat/lon = центроид ЖК-ПЛОЩАДКИ (несколько домов + дворы), поэтому
|
||
# смещён от ближайшего building-footprint; шире радиус ловит больше, но рискует
|
||
# приписать СОСЕДНЕЕ здание (precision важнее recall — parking_ratio чужого дома
|
||
# хуже отсутствия). ST_Contains (точка внутри footprint) всегда матчит независимо
|
||
# от радиуса. Caller может расширить через max_dist_m.
|
||
_DEFAULT_MATCH_RADIUS_M: float = 50.0
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class BuildingMatch:
|
||
"""Результат geom-match domrf-объекта на здание ЕГРН (#96 wiring).
|
||
|
||
cad_num — кадастровый номер сматченного здания (ключ к premises-lookup).
|
||
objdoc_id — НСПД objdoc этого здания ИЗ cad_buildings (может быть None для
|
||
части строк). Если он есть — get_building_premises может ПРОПУСТИТЬ НСПД
|
||
search_by_cad и сразу дёрнуть tab-group (1 НСПД-запрос вместо 2).
|
||
distance_m — расстояние от domrf-центроида до footprint (0.0 = точка внутри
|
||
площадного footprint).
|
||
"""
|
||
|
||
cad_num: str
|
||
objdoc_id: int | None
|
||
distance_m: float
|
||
|
||
|
||
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||
_RESOLVE_CAD_SQL = text("""
|
||
SELECT
|
||
b.cad_num,
|
||
b.objdoc_id,
|
||
ST_Distance(
|
||
b.geom::geography,
|
||
ST_SetSRID(
|
||
ST_MakePoint(
|
||
CAST(:lon AS double precision),
|
||
CAST(:lat AS double precision)
|
||
),
|
||
4326
|
||
)::geography
|
||
) AS distance_m
|
||
FROM cad_buildings b
|
||
WHERE b.geom IS NOT NULL
|
||
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||
ST_MakePoint(
|
||
CAST(:lon AS double precision),
|
||
CAST(:lat AS double precision)
|
||
),
|
||
4326
|
||
)::geography
|
||
LIMIT 1
|
||
""")
|
||
|
||
|
||
def resolve_cad_for_domrf(
|
||
db: Session,
|
||
*,
|
||
lat: float,
|
||
lon: float,
|
||
max_dist_m: float = _DEFAULT_MATCH_RADIUS_M,
|
||
) -> BuildingMatch | None:
|
||
"""Резолв domrf-объекта (lat/lon центроид) → здание cad_buildings (#96 wiring).
|
||
|
||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||
площадного footprint → distance 0.
|
||
|
||
Returns:
|
||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||
радиусе, иначе None. None трактуется как «здание не сопоставлено» —
|
||
caller продолжает без parking_ratio (graceful, не выдумываем).
|
||
|
||
Note:
|
||
objdoc_id из cad_buildings возвращается для short-circuit НСПД search в
|
||
get_building_premises_for_match — может быть None (тогда фоллбэк на
|
||
cad_num search).
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
row = (
|
||
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception:
|
||
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
|
||
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).
|
||
logger.exception("premises_lookup: resolve_cad_for_domrf failed lat=%s lon=%s", lat, lon)
|
||
return None
|
||
|
||
if row is None:
|
||
return None
|
||
|
||
distance_m = float(row["distance_m"])
|
||
if distance_m > max_dist_m:
|
||
logger.info(
|
||
"premises_lookup: nearest building %.1fm > %.1fm radius for lat=%s lon=%s — no match",
|
||
distance_m,
|
||
max_dist_m,
|
||
lat,
|
||
lon,
|
||
)
|
||
return None
|
||
|
||
raw_objdoc = row["objdoc_id"]
|
||
objdoc_id = int(raw_objdoc) if raw_objdoc is not None else None
|
||
return BuildingMatch(cad_num=row["cad_num"], objdoc_id=objdoc_id, distance_m=distance_m)
|
||
|
||
|
||
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
|
||
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
||
|
||
Возвращает None если здание не найдено или НСПД не отдал objdocId (часть
|
||
объектов его не имеет). Сетевые/WAF-ошибки логируются и дают None — caller
|
||
трактует как «нет данных», analyze не падает (graceful).
|
||
"""
|
||
try:
|
||
result = client.search_by_cad(cad_num, thematic_id=_THEMATIC_BUILDING)
|
||
except NspdLiteError as e:
|
||
# WAF/сеть/4xx — не наш баг, не валим analyze. Один building без
|
||
# parking_ratio лучше, чем 500 на весь отчёт.
|
||
logger.warning("premises_lookup: search_by_cad failed cad_num=%s: %s", cad_num, e)
|
||
return None
|
||
|
||
feature = result.first
|
||
if feature is None:
|
||
logger.info("premises_lookup: здание не найдено в НСПД cad_num=%s", cad_num)
|
||
return None
|
||
|
||
options = feature.properties.get("options") or {}
|
||
raw = options.get("objdocId") or options.get("objdoc_id")
|
||
if raw is None:
|
||
# Fallback: feature.id у зданий = objdoc (verified live на
|
||
# 66:41:0106036:183 — tab-group по feature.id вернул помещения).
|
||
raw = feature.feature_id
|
||
if raw is None:
|
||
logger.info("premises_lookup: нет objdoc_id для cad_num=%s", cad_num)
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except (TypeError, ValueError):
|
||
logger.warning("premises_lookup: невалидный objdoc_id %r для cad_num=%s", raw, cad_num)
|
||
return None
|
||
|
||
|
||
async def _fetch_listing(objdoc_id: int) -> ObjectsListing | None:
|
||
"""list_objects_in_building в одноразовом async-клиенте.
|
||
|
||
Изолированный async context (`async with`) — корректный lifecycle сокета +
|
||
semaphore под текущим event loop (см. NSPDBulkClient.__aenter__ / #260).
|
||
"""
|
||
try:
|
||
async with NSPDBulkClient() as client:
|
||
return await client.list_objects_in_building(objdoc_id)
|
||
except NspdBulkError as e:
|
||
logger.warning("premises_lookup: list_objects failed objdoc_id=%d: %s", objdoc_id, e)
|
||
return None
|
||
|
||
|
||
def get_building_premises(cad_num: str) -> ObjectsListing | None:
|
||
"""Помещения + машино-места + parking_ratio для здания по кад. номеру (#96).
|
||
|
||
On-demand, 2 НСПД-запроса. Возвращает None если здание не резолвится в
|
||
objdoc_id или НСПД недоступен (graceful — caller продолжает без сигнала).
|
||
`ObjectsListing.parking_ratio` = машино-места / помещения (None если
|
||
помещений 0).
|
||
|
||
Args:
|
||
cad_num: кадастровый номер ЗДАНИЯ (5-сегментный, напр.
|
||
`66:41:0106036:183`). Квартал/участок дадут пустой/нерелевантный
|
||
результат — caller обязан передавать именно здание.
|
||
|
||
Returns:
|
||
ObjectsListing | None. None трактуется как «нет данных», НЕ как нулевой
|
||
паркинг (не выдумываем дефицит из недоступности НСПД).
|
||
"""
|
||
sync_client = NSPDClient()
|
||
objdoc_id = _resolve_objdoc_id(cad_num, sync_client)
|
||
if objdoc_id is None:
|
||
return None
|
||
|
||
listing = asyncio.run(_fetch_listing(objdoc_id))
|
||
if listing is not None:
|
||
logger.info(
|
||
"premises_lookup: cad_num=%s objdoc_id=%d flats=%d parking=%d ratio=%s",
|
||
cad_num,
|
||
objdoc_id,
|
||
listing.flats_count,
|
||
listing.parking_count,
|
||
listing.parking_ratio,
|
||
)
|
||
return listing
|
||
|
||
|
||
def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None:
|
||
"""Помещения/parking_ratio для уже-сматченного здания (#96 wiring).
|
||
|
||
Тонкая обёртка над get_building_premises: если geom-match дал objdoc_id ИЗ
|
||
cad_buildings, ПРОПУСКАЕМ НСПД search_by_cad (1 НСПД-запрос вместо 2 — search
|
||
тратит rate-limit и иногда промахивается на не-канон cad_num). Иначе фоллбэк
|
||
на cad_num-резолв через get_building_premises.
|
||
|
||
Graceful: НСПД недоступен / objdoc не отдал помещения → None (как у
|
||
get_building_premises). Изолирует async в asyncio.run() — вызывается из
|
||
sync-контекста (FastAPI threadpool handler / Celery task).
|
||
"""
|
||
if match.objdoc_id is not None:
|
||
listing = asyncio.run(_fetch_listing(match.objdoc_id))
|
||
if listing is not None:
|
||
logger.info(
|
||
"premises_lookup: match cad_num=%s objdoc_id=%d (cad_buildings) "
|
||
"flats=%d parking=%d ratio=%s",
|
||
match.cad_num,
|
||
match.objdoc_id,
|
||
listing.flats_count,
|
||
listing.parking_count,
|
||
listing.parking_ratio,
|
||
)
|
||
return listing
|
||
# objdoc неизвестен в cad_buildings — полный путь по cad_num (2 НСПД-запроса).
|
||
return get_building_premises(match.cad_num)
|
||
|
||
|
||
__all__ = [
|
||
"BuildingMatch",
|
||
"get_building_premises",
|
||
"get_building_premises_for_match",
|
||
"resolve_cad_for_domrf",
|
||
]
|