All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
Оживляет мёртвый foundation list_objects_in_building (#168 Q3-deferred). Fix реального блокера: NSPDOptions не парсил objdocId (camelCase из NSPD search) → objdoc_id всегда None, метод нельзя было вызвать. AliasChoices(objdoc_id, objdocId) + registers. premises_lookup.get_building_premises(cad_num) — резолв objdoc → list_objects_in_building, graceful (WAF/сеть/not-found → None). parking_ratio = машино-места/помещения (None при 0 помещений, 0.0 при реальном отсутствии паркинга). Verified live 66:41:0106036:183. MVP on-demand (без bulk/схемы/prod-записей). parking_ratio готов, но НЕ wired в analyze: конкуренты приходят из ДОМ.РФ без cad_num → нужен domrf↔cad_buildings geom-match (отдельная задача). Сервис примет cad_num как только matching появится. 11 тестов, ruff clean. Refs #96
130 lines
6.4 KiB
Python
130 lines
6.4 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 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"]
|