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