From 7e2065a74fec9f209e51631d519e22cad0c049a0 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 19:17:30 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(site-finder):=20wire=20domrf=E2=86=92c?= =?UTF-8?q?ad=5Fbuildings=20geom-match=20+=20parking=5Fratio=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domrf-конкуренты живут в lat/lon без cad_num — PostGIS-мост resolve_cad_for_domrf (GIST KNN nearest ≤50м + ST_Contains footprint) → on-demand НСПД premises → parking_ratio. Prod: 47% domrf-объектов (1543) матчатся ≤50м, медиана 56м. Доставка через ленивый POST /{cad}/competitors-parking (top-3), НЕ inline в analyze/forecast (N×НСПД убил бы p95). cad_buildings.objdoc_id short-circuit (1 НСПД-запрос вместо 2). Graceful → None. 16 тестов. Завершает #1326 MVP (premises_lookup) — parking_ratio теперь доходит до конкурентов. Closes #96 --- backend/app/api/v1/parcels.py | 33 +- backend/app/schemas/parcel.py | 37 +++ .../app/services/site_finder/competitors.py | 90 ++++++ .../services/site_finder/premises_lookup.py | 157 +++++++++- .../site_finder/test_competitors_parking.py | 286 ++++++++++++++++++ 5 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/site_finder/test_competitors_parking.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 77d09276..80c5e04b 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -22,6 +22,7 @@ from app.schemas.parcel import ( AnalyzeResponse, BestLayoutsRequest, BestLayoutsResponse, + CompetitorsParkingResponse, CompetitorsRequest, CompetitorsResponse, ConnectionPointsResponse, @@ -58,7 +59,7 @@ from app.services.site_finder.cadastre_fetch import ( from app.services.site_finder.cadastre_fetch import ( fetch_status as _fetch_status, ) -from app.services.site_finder.competitors import get_competitors +from app.services.site_finder.competitors import get_competitors, get_competitors_parking from app.services.site_finder.custom_pois import ( get_overlaps_for_scoring as _get_custom_poi_overlaps, ) @@ -3151,6 +3152,36 @@ async def get_parcel_competitors( ) from exc +@router.post("/{cad_num}/competitors-parking", response_model=CompetitorsParkingResponse) +def get_parcel_competitors_parking( + cad_num: str, + body: CompetitorsRequest, + db: Annotated[Session, Depends(get_db)], +) -> CompetitorsParkingResponse: + """parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze. + + Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через + PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio. + Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы + секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после + отрисовки списка конкурентов. + + sync `def` (не async): premises_lookup изолирует НСПД-async через + asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI + исполняет sync-handler в threadpool, не блокируя event loop. + """ + try: + return get_competitors_parking(db=db, cad_num=cad_num, request=body) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + logger.error("competitors-parking endpoint failed for %s: %s", cad_num, exc) + raise HTTPException( + status_code=500, + detail="Ошибка расчёта паркинга конкурентов", + ) from exc + + @router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse) async def get_parcel_best_layouts( cad_num: str, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 25b3b7f0..4ef73c02 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -220,6 +220,43 @@ class CompetitorsResponse(BaseModel): summary: CompetitorsSummary +# ── #96 wiring: parking_ratio конкурентов (lazy-endpoint, НЕ inline в analyze) ── +# domrf-конкуренты живут в lat/lon без cad_num → geom-match на cad_buildings → +# on-demand НСПД premises-lookup. Дорого (N×НСПД ~0.6-2с) → отдельный ленивый +# эндпоинт /{cad}/competitors-parking, строгий top-N, фронт дёргает после +# отрисовки конкурентов. НЕ блокирует analyze/forecast p95. + + +class CompetitorParking(BaseModel): + """parking_ratio одного конкурента (#96). obj_id связывает с Competitor. + + matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид + (None если geom-match не нашёл здание в радиусе). parking_ratio / + parking_count / flats_count — из НСПД tab-group (None если здание не + сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг). + match_distance_m — расстояние domrf-центроид → footprint (диагностика). + """ + + obj_id: int + matched_cad_num: str | None = None + match_distance_m: float | None = None + parking_ratio: float | None = None + parking_count: int | None = None + flats_count: int | None = None + + +class CompetitorsParkingResponse(BaseModel): + """Ответ ленивого /{cad}/competitors-parking (#96). + + items — по одному CompetitorParking на top-N конкурентов (по relevance_weight + DESC из get_competitors). matched_count — сколько реально получили + parking_ratio (диагностика покрытия для фронта / мониторинга). + """ + + items: list[CompetitorParking] + matched_count: int + + # ── Developer attribution (#1088 «GG-форсайт») ───────────────────────────────── # Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка # + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel / diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 3173d75d..1e25af95 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -24,14 +24,27 @@ from sqlalchemy.orm import Session from app.schemas.parcel import ( Competitor, + CompetitorParking, + CompetitorsParkingResponse, CompetitorsRequest, CompetitorsResponse, CompetitorsSummary, ) from app.services.forecast_request_cache import cached +from app.services.site_finder.premises_lookup import ( + get_building_premises_for_match, + resolve_cad_for_domrf, +) logger = logging.getLogger(__name__) +# #96 wiring: сколько top-конкурентов (по relevance_weight DESC) обогащаем +# parking_ratio в ленивом эндпоинте. Строгий top-N: каждый = geom-match SQL +# (~19ms) + НСПД premises (~0.6-2с). 3 — баланс «достаточно для сравнения дефицита +# паркинга» vs суммарная latency (~2-6с на ленивый вызов, ВНЕ analyze/forecast +# хот-пути). Расширять с осторожностью — линейно множит НСПД-запросы. +_PARKING_TOP_N: int = 3 + def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]: """Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод. @@ -679,3 +692,80 @@ def get_competitors( ) return CompetitorsResponse(competitors=competitors, summary=summary) + + +def get_competitors_parking( + db: Session, + cad_num: str, + request: CompetitorsRequest, + *, + top_n: int = _PARKING_TOP_N, +) -> CompetitorsParkingResponse: + """parking_ratio top-N конкурентов через geom-match + НСПД premises (#96). + + Ленивый путь (НЕ inline в analyze/forecast — см. parcel.py CompetitorParking + docstring про p95). Для каждого из top-N конкурентов (get_competitors уже + сортирует по relevance_weight DESC): + 1. resolve_cad_for_domrf(lat, lon) → ближайшее cad_buildings-здание (PostGIS + KNN ≤ радиуса). Промах → parking отсутствует, НЕ падаем. + 2. get_building_premises_for_match(match) → НСПД tab-group → parking_ratio. + (objdoc_id из cad_buildings экономит НСПД search-запрос.) + + Каждый конкурент изолирован try/except: сбой одного (геометрия/НСПД/WAF) не + рушит остальные и не валит эндпоинт (graceful, ТЗ §15). Конкуренты без + geom-match всё равно попадают в items с None-полями (стабильный контракт + + фронт видит «искали, не нашли»). + + Raises: + ValueError: если центроид участка не найден (из get_competitors — caller + возвращает 404). + """ + response = get_competitors(db=db, cad_num=cad_num, request=request) + top = response.competitors[:top_n] + + items: list[CompetitorParking] = [] + matched_count = 0 + for competitor in top: + item = CompetitorParking(obj_id=competitor.obj_id) + try: + match = resolve_cad_for_domrf(db, lat=competitor.lat, lon=competitor.lng) + except Exception: + # resolve_cad_for_domrf уже глотает SQL-сбои → None; этот guard на + # случай неожиданного исключения — один конкурент без паркинга, не 500. + logger.exception( + "competitors_parking: geom-match raised for obj_id=%s", competitor.obj_id + ) + match = None + + if match is not None: + item = item.model_copy( + update={ + "matched_cad_num": match.cad_num, + "match_distance_m": round(match.distance_m, 1), + } + ) + try: + listing = get_building_premises_for_match(match) + except Exception: + # НСПД-клиент уже graceful (NspdLiteError/NspdBulkError → None); + # неожиданное — логируем и продолжаем без parking для этого ЖК. + logger.exception( + "competitors_parking: premises lookup raised for obj_id=%s cad_num=%s", + competitor.obj_id, + match.cad_num, + ) + listing = None + if listing is not None: + item = item.model_copy( + update={ + "parking_ratio": listing.parking_ratio, + "parking_count": listing.parking_count, + "flats_count": listing.flats_count, + } + ) + if listing.parking_ratio is not None: + matched_count += 1 + + items.append(item) + + return CompetitorsParkingResponse(items=items, matched_count=matched_count) diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py index 20a8f15b..892a0be7 100644 --- a/backend/app/services/site_finder/premises_lookup.py +++ b/backend/app/services/site_finder/premises_lookup.py @@ -30,6 +30,10 @@ 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 @@ -41,6 +45,123 @@ logger = logging.getLogger(__name__) # 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). +# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает +# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по +# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на +# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно +# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка +# внутри площадного footprint даёт ST_Distance=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 <-> ST_SetSRID( + ST_MakePoint( + CAST(:lon AS double precision), + CAST(:lat AS double precision) + ), + 4326 + ) + 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 <-> point`) и принимаем матч, если расстояние до + 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. @@ -127,4 +248,38 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None: return listing -__all__ = ["get_building_premises"] +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", +] diff --git a/backend/tests/services/site_finder/test_competitors_parking.py b/backend/tests/services/site_finder/test_competitors_parking.py new file mode 100644 index 00000000..58fb795d --- /dev/null +++ b/backend/tests/services/site_finder/test_competitors_parking.py @@ -0,0 +1,286 @@ +"""Тесты geom-match domrf↔cad_buildings + wire parking_ratio (#96 wiring). + +Без живой БД и без сети: мокаем db.execute (PostGIS KNN-строка), get_competitors, +resolve_cad_for_domrf и get_building_premises_for_match. Проверяем: + • resolve_cad_for_domrf — точка внутри footprint → cad_num; вне радиуса → None; + нет зданий → None; objdoc_id из cad_buildings прокидывается. + • get_competitors_parking — top-N конкурентов получают parking_ratio; промах + geom-match / None-premises → поля None (graceful, не падаем), matched_count. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.nspd_bulk import ObjectsListing +from app.schemas.parcel import ( + Competitor, + CompetitorsRequest, + CompetitorsResponse, + CompetitorsSummary, +) +from app.services.site_finder import competitors as competitors_mod +from app.services.site_finder import premises_lookup as pl +from app.services.site_finder.premises_lookup import BuildingMatch, resolve_cad_for_domrf + +# ── resolve_cad_for_domrf (geom-match) ───────────────────────────────────────── + + +def _db_returning(row: dict[str, Any] | None) -> MagicMock: + """Мок Session.execute(...).mappings().first() → row (или None).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.first.return_value = row + return db + + +def test_resolve_cad_inside_footprint_returns_cad() -> None: + """Точка внутри footprint (distance 0) → BuildingMatch с cad_num + objdoc_id.""" + db = _db_returning({"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 0.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) + + assert match is not None + assert match.cad_num == "66:41:0106036:183" + assert match.objdoc_id == 40995027 + assert match.distance_m == 0.0 + + +def test_resolve_cad_within_radius_returns_cad() -> None: + """Ближайшее здание в пределах радиуса (30м ≤ 50м) → матч.""" + db = _db_returning({"cad_num": "66:41:0106036:9", "objdoc_id": None, "distance_m": 30.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) + + assert match is not None + assert match.cad_num == "66:41:0106036:9" + # objdoc_id NULL в cad_buildings → None (фоллбэк на cad_num search в lookup). + assert match.objdoc_id is None + assert match.distance_m == 30.0 + + +def test_resolve_cad_beyond_radius_returns_none() -> None: + """Ближайшее здание дальше радиуса (80м > 50м) → None (не приписываем чужое).""" + db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +def test_resolve_cad_custom_radius_accepts_far_match() -> None: + """max_dist_m=100 принимает 80м-матч, который дефолтные 50м отвергли бы.""" + db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6, max_dist_m=100.0) + + assert match is not None + assert match.cad_num == "66:41:0106036:1" + + +def test_resolve_cad_no_buildings_returns_none() -> None: + """Нет зданий с geom (пустой результат) → None.""" + db = _db_returning(None) + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +def test_resolve_cad_sql_error_returns_none() -> None: + """SQL/PostGIS-сбой → None (graceful, lazy-endpoint не падает).""" + db = MagicMock() + db.execute.side_effect = RuntimeError("postgis boom") + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +# ── get_competitors_parking (wire) ────────────────────────────────────────────── + + +def _competitor(obj_id: int, lat: float = 56.84, lng: float = 60.6) -> Competitor: + """Минимальный Competitor для wire-теста (только нужные wire поля заполнены).""" + return Competitor( + obj_id=obj_id, + comm_name=f"ЖК-{obj_id}", + dev_name=None, + obj_class="комфорт", + distance_m=100.0, + lat=lat, + lng=lng, + stage="Строящиеся", + flats_total=200, + flats_sold=None, + sold_pct=None, + velocity_per_month=5.0, + avg_price_per_m2=None, + is_active=True, + ) + + +def _competitors_response(*obj_ids: int) -> CompetitorsResponse: + comps = [_competitor(oid) for oid in obj_ids] + return CompetitorsResponse( + competitors=comps, + summary=CompetitorsSummary( + total_competitors=len(comps), + active_count=len(comps), + weighted_avg_velocity=5.0, + radius_km=1.0, + time_window="last_quarter", + ), + ) + + +def test_parking_wire_happy_path(monkeypatch: Any) -> None: + """top-конкурент сматчился на здание → получает parking_ratio из НСПД.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(101) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3), + ) + listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) + monkeypatch.setattr( + competitors_mod, "get_building_premises_for_match", lambda _m: listing + ) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 1 + assert len(out.items) == 1 + item = out.items[0] + assert item.obj_id == 101 + assert item.matched_cad_num == "66:41:0106036:183" + assert item.match_distance_m == 12.3 + assert item.parking_ratio == 0.5 + assert item.parking_count == 100 + assert item.flats_count == 200 + + +def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None: + """Из 5 конкурентов обогащаются только первые top_n (строгий лимит).""" + monkeypatch.setattr( + competitors_mod, + "get_competitors", + lambda **_: _competitors_response(1, 2, 3, 4, 5), + ) + calls: list[float] = [] + + def _resolve(_db: Any, *, lat: float, lon: float) -> BuildingMatch: + calls.append(lat) + return BuildingMatch("66:41:0106036:1", 1, 0.0) + + monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", _resolve) + monkeypatch.setattr( + competitors_mod, + "get_building_premises_for_match", + lambda _m: ObjectsListing(objdoc_id=1, flats_count=10, parking_count=5), + ) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest(), top_n=2 + ) + + assert len(out.items) == 2 + assert len(calls) == 2 # geom-match вызван ровно top_n раз — не на всех 5 + assert {i.obj_id for i in out.items} == {1, 2} + + +def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None: + """geom-match промахнулся → конкурент в items с None-полями, matched_count=0.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(7) + ) + monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None) + premises = MagicMock() + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].obj_id == 7 + assert out.items[0].matched_cad_num is None + assert out.items[0].parking_ratio is None + premises.assert_not_called() # без матча НСПД не дёргаем + + +def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None: + """Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(9) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:5", None, 40.0), + ) + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: None) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].matched_cad_num == "66:41:0106036:5" + assert out.items[0].match_distance_m == 40.0 + assert out.items[0].parking_ratio is None + + +def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None: + """Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(11) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:5", 1, 0.0), + ) + + def _boom(_m: Any) -> Any: + raise RuntimeError("nspd exploded") + + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", _boom) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].matched_cad_num == "66:41:0106036:5" + assert out.items[0].parking_ratio is None + + +# ── get_building_premises_for_match (short-circuit objdoc) ────────────────────── + + +def test_premises_for_match_uses_objdoc_skips_search(monkeypatch: Any) -> None: + """objdoc_id из cad_buildings → пропускаем НСПД search, сразу tab-group.""" + listing = ObjectsListing(objdoc_id=555, flats_count=50, parking_count=60) + monkeypatch.setattr(pl.asyncio, "run", lambda coro: (coro.close(), listing)[1]) + # search_by_cad НЕ должен вызываться при known objdoc_id. + full_path = MagicMock() + monkeypatch.setattr(pl, "get_building_premises", full_path) + + out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", 555, 0.0)) + + assert out is listing + full_path.assert_not_called() + + +def test_premises_for_match_no_objdoc_falls_back(monkeypatch: Any) -> None: + """objdoc_id None → фоллбэк на get_building_premises(cad_num) (2 НСПД-запроса).""" + listing = ObjectsListing(objdoc_id=1, flats_count=10, parking_count=3) + fallback = MagicMock(return_value=listing) + monkeypatch.setattr(pl, "get_building_premises", fallback) + + out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", None, 0.0)) + + assert out is listing + fallback.assert_called_once_with("66:41:0106036:5") -- 2.45.3 From 3dee18743d5c334323af140f8427e5f167f9e884 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 19:22:33 +0500 Subject: [PATCH 2/2] =?UTF-8?q?chore(frontend):=20regen=20api-types.ts=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20/competitors-parking=20endpoint=20(#96=20c?= =?UTF-8?q?odegen-gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/api-types.ts | 103 ++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 9a177f01..d1d9c8e1 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -409,6 +409,36 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/parcels/{cad_num}/competitors-parking": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Parcel Competitors Parking + * @description parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze. + * + * Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через + * PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio. + * Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы + * секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после + * отрисовки списка конкурентов. + * + * sync `def` (не async): premises_lookup изолирует НСПД-async через + * asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI + * исполняет sync-handler в threadpool, не блокируя event loop. + */ + post: operations["get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/parcels/{cad_num}/best-layouts": { parameters: { query?: never; @@ -3049,6 +3079,44 @@ export interface components { [key: string]: number; } | null; }; + /** + * CompetitorParking + * @description parking_ratio одного конкурента (#96). obj_id связывает с Competitor. + * + * matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид + * (None если geom-match не нашёл здание в радиусе). parking_ratio / + * parking_count / flats_count — из НСПД tab-group (None если здание не + * сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг). + * match_distance_m — расстояние domrf-центроид → footprint (диагностика). + */ + CompetitorParking: { + /** Obj Id */ + obj_id: number; + /** Matched Cad Num */ + matched_cad_num?: string | null; + /** Match Distance M */ + match_distance_m?: number | null; + /** Parking Ratio */ + parking_ratio?: number | null; + /** Parking Count */ + parking_count?: number | null; + /** Flats Count */ + flats_count?: number | null; + }; + /** + * CompetitorsParkingResponse + * @description Ответ ленивого /{cad}/competitors-parking (#96). + * + * items — по одному CompetitorParking на top-N конкурентов (по relevance_weight + * DESC из get_competitors). matched_count — сколько реально получили + * parking_ratio (диагностика покрытия для фронта / мониторинга). + */ + CompetitorsParkingResponse: { + /** Items */ + items: components["schemas"]["CompetitorParking"][]; + /** Matched Count */ + matched_count: number; + }; /** CompetitorsRequest */ CompetitorsRequest: { /** @@ -5052,6 +5120,41 @@ export interface operations { }; }; }; + get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post: { + parameters: { + query?: never; + header?: never; + path: { + cad_num: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CompetitorsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CompetitorsParkingResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: { parameters: { query?: never; -- 2.45.3