"""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 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 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 __all__ = ["get_building_premises"]