From 2c22c3f7ea065b9354f05eb9eec2065cd4239f7b Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 18:48:16 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20per-building=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D1=8F/=D0=BC=D0=B0?= =?UTF-8?q?=D1=88=D0=B8=D0=BD=D0=BE-=D0=BC=D0=B5=D1=81=D1=82=D0=B0=20+=20p?= =?UTF-8?q?arking=5Fratio=20(on-demand=20MVP,=20#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оживляет мёртвый 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 --- backend/app/schemas/nspd_bulk.py | 30 +++- .../services/site_finder/premises_lookup.py | 130 +++++++++++++++ .../tests/scrapers/test_nspd_bulk_client.py | 40 +++++ .../site_finder/test_premises_lookup.py | 150 ++++++++++++++++++ 4 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/site_finder/premises_lookup.py create mode 100644 backend/tests/services/site_finder/test_premises_lookup.py diff --git a/backend/app/schemas/nspd_bulk.py b/backend/app/schemas/nspd_bulk.py index 27751fdf..7aaa4a5e 100644 --- a/backend/app/schemas/nspd_bulk.py +++ b/backend/app/schemas/nspd_bulk.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field class NSPDOptions(BaseModel): @@ -42,8 +42,19 @@ class NSPDOptions(BaseModel): category_type: str | None = None build_record_type: str | None = None build_record_type_value: str | None = None - objdoc_id: int | None = None - registers_id: int | None = None + # NSPD geoportal search отдаёт objdoc/registers в camelCase (objdocId / + # registersId), а bulk grid-walk иногда в snake_case. AliasChoices ловит оба + # (verified live на 66:41:0106036:183 — search вернул objdocId=40995027, + # snake-only парсинг давал None и ломал list_objects_in_building). objdoc_id — + # ключ к tab-group objectsList (помещения/машино-места). + objdoc_id: int | None = Field( + default=None, + validation_alias=AliasChoices("objdoc_id", "objdocId"), + ) + registers_id: int | None = Field( + default=None, + validation_alias=AliasChoices("registers_id", "registersId"), + ) okato: str | None = None kladr: str | None = None @@ -142,6 +153,19 @@ class ObjectsListing(BaseModel): flats_cad_nums: list[str] = [] parking_cad_nums: list[str] = [] + @property + def parking_ratio(self) -> float | None: + """Обеспеченность паркингом: машино-места / помещения (#96). + + Сигнал класса/дефицита для recommend: бизнес обычно ≥1.0, эконом <0.5. + None если помещений 0 (нельзя делить) — caller трактует как «нет данных», + НЕ как 0 (graceful, не выдумываем дефицит из пустоты). Округление до 0.01 + — детерминированно, без накопления float-шума при агрегации по соседям. + """ + if self.flats_count <= 0: + return None + return round(self.parking_count / self.flats_count, 2) + __all__ = [ "NSPDBulkFeature", diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py new file mode 100644 index 00000000..20a8f15b --- /dev/null +++ b/backend/app/services/site_finder/premises_lookup.py @@ -0,0 +1,130 @@ +"""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"] diff --git a/backend/tests/scrapers/test_nspd_bulk_client.py b/backend/tests/scrapers/test_nspd_bulk_client.py index b98bb7e9..bc57857a 100644 --- a/backend/tests/scrapers/test_nspd_bulk_client.py +++ b/backend/tests/scrapers/test_nspd_bulk_client.py @@ -210,6 +210,46 @@ def test_objects_listing_defaults() -> None: assert listing.flats_cad_nums == [] +def test_nspd_options_parses_camelcase_objdoc_id() -> None: + """#96: NSPD search отдаёт objdocId/registersId camelCase → AliasChoices ловит. + + Regression: snake-only парсинг давал None (verified live на + 66:41:0106036:183, objdocId=40995027) и ломал list_objects_in_building. + """ + from app.schemas.nspd_bulk import NSPDOptions + + opts = NSPDOptions.model_validate({"objdocId": 40995027, "registersId": 36441}) + assert opts.objdoc_id == 40995027 + assert opts.registers_id == 36441 + + +def test_nspd_options_snake_case_objdoc_still_works() -> None: + """#96: bulk grid-walk snake_case objdoc_id по-прежнему парсится (back-compat).""" + from app.schemas.nspd_bulk import NSPDOptions + + opts = NSPDOptions.model_validate({"objdoc_id": 777, "registers_id": 36441}) + assert opts.objdoc_id == 777 + assert opts.registers_id == 36441 + + +def test_objects_listing_parking_ratio() -> None: + """#96: parking_ratio = машино-места / помещения, округление 0.01.""" + listing = ObjectsListing(objdoc_id=1, flats_count=200, parking_count=240) + assert listing.parking_ratio == 1.2 + + +def test_objects_listing_parking_ratio_none_when_no_flats() -> None: + """#96: parking_ratio = None при flats_count=0 (нельзя делить, не выдумываем 0).""" + listing = ObjectsListing(objdoc_id=1, flats_count=0, parking_count=0) + assert listing.parking_ratio is None + + +def test_objects_listing_parking_ratio_zero_parking() -> None: + """#96: 0 машино-мест при N помещений → ratio 0.0 (реальный дефицит, не None).""" + listing = ObjectsListing(objdoc_id=1, flats_count=232, parking_count=0) + assert listing.parking_ratio == 0.0 + + # ── Unit tests: client parse logic (mocked HTTP) ───────────────────────────── diff --git a/backend/tests/services/site_finder/test_premises_lookup.py b/backend/tests/services/site_finder/test_premises_lookup.py new file mode 100644 index 00000000..5ef64877 --- /dev/null +++ b/backend/tests/services/site_finder/test_premises_lookup.py @@ -0,0 +1,150 @@ +"""Тесты premises_lookup (#96 MVP on-demand per-building помещения/паркинг). + +Сетевые вызовы НЕ делаем — мокаем sync NSPDClient.search_by_cad (резолв objdoc) +и async NSPDBulkClient.list_objects_in_building (tab-group counts). Проверяем +happy path + graceful-фоллбэки (здание не найдено / нет objdoc / WAF). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from app.schemas.nspd_bulk import ObjectsListing +from app.services.scrapers.nspd_client import NspdLiteWafError + +MODULE = "app.services.site_finder.premises_lookup" + + +def _run_returns(listing: ObjectsListing | None) -> Any: + """side_effect для мока asyncio.run: закрывает переданную корутину и отдаёт listing. + + Без close() реальная корутина _fetch_listing(...) остаётся never-awaited + (RuntimeWarning), т.к. мок asyncio.run её не исполняет. Закрываем явно. + """ + + def _side_effect(coro: Any) -> ObjectsListing | None: + coro.close() + return listing + + return _side_effect + + +def _search_result(options: dict[str, Any] | None, feature_id: str | None = "999") -> MagicMock: + """Замокать NSPDSearchResult.first → NSPDFeature-подобный объект.""" + result = MagicMock() + if options is None and feature_id is None: + result.first = None + return result + feature = MagicMock() + feature.properties = {"options": options or {}} + feature.feature_id = feature_id + result.first = feature + return result + + +def test_get_building_premises_happy_path() -> None: + """search → objdocId → list_objects → ObjectsListing с parking_ratio.""" + from app.services.site_finder.premises_lookup import get_building_premises + + listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run, + ): + mock_client_cls.return_value.search_by_cad.return_value = _search_result( + {"objdocId": 40995027} + ) + out = get_building_premises("66:41:0106036:183") + + assert out is listing + assert out.parking_ratio == 0.5 + mock_run.assert_called_once() + + +def test_get_building_premises_snake_case_objdoc() -> None: + """objdoc_id в snake_case тоже резолвится (bulk-shape).""" + from app.services.site_finder.premises_lookup import get_building_premises + + listing = ObjectsListing(objdoc_id=777, flats_count=10, parking_count=2) + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)), + ): + mock_client_cls.return_value.search_by_cad.return_value = _search_result( + {"objdoc_id": 777} + ) + out = get_building_premises("66:41:0106036:183") + + assert out is listing + + +def test_get_building_premises_fallback_to_feature_id() -> None: + """Нет objdocId в options → fallback на feature.id (verified live: id=objdoc).""" + from app.services.site_finder.premises_lookup import get_building_premises + + listing = ObjectsListing(objdoc_id=12345, flats_count=5, parking_count=0) + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run, + ): + mock_client_cls.return_value.search_by_cad.return_value = _search_result( + {}, feature_id="12345" + ) + out = get_building_premises("66:41:0106036:183") + + assert out is listing + # objdoc_id=12345 должен был уйти в _fetch_listing + mock_run.assert_called_once() + + +def test_get_building_premises_not_found_returns_none() -> None: + """Здание не найдено в НСПД (result.first=None) → None, async не дёргается.""" + from app.services.site_finder.premises_lookup import get_building_premises + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run") as mock_run, + ): + mock_client_cls.return_value.search_by_cad.return_value = _search_result( + None, feature_id=None + ) + out = get_building_premises("66:41:0106036:183") + + assert out is None + mock_run.assert_not_called() + + +def test_get_building_premises_no_objdoc_returns_none() -> None: + """Нет ни objdocId, ни feature.id → None (нельзя дёрнуть tab-group).""" + from app.services.site_finder.premises_lookup import get_building_premises + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run") as mock_run, + ): + mock_client_cls.return_value.search_by_cad.return_value = _search_result( + {}, feature_id=None + ) + out = get_building_premises("66:41:0106036:183") + + assert out is None + mock_run.assert_not_called() + + +def test_get_building_premises_waf_returns_none() -> None: + """WAF/сеть на search_by_cad → None (graceful, analyze не падает).""" + from app.services.site_finder.premises_lookup import get_building_premises + + with ( + patch(f"{MODULE}.NSPDClient") as mock_client_cls, + patch(f"{MODULE}.asyncio.run") as mock_run, + ): + mock_client_cls.return_value.search_by_cad.side_effect = NspdLiteWafError("HTTP 403 WAF") + out = get_building_premises("66:41:0106036:183") + + assert out is None + mock_run.assert_not_called() -- 2.45.3