gendesign/backend/app/services/site_finder/premises_lookup.py
Light1YT 2c22c3f7ea
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
feat(site-finder): per-building помещения/машино-места + parking_ratio (on-demand MVP, #96)
Оживляет мёртвый 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
2026-06-14 18:48:16 +05:00

130 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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