feat(site-finder): domrf→cad geom-match + parking_ratio конкурентов (#96) #1327

Merged
bot-backend merged 2 commits from feat/domrf-cad-match-parking-wire-96 into main 2026-06-14 14:49:29 +00:00
6 changed files with 704 additions and 2 deletions

View file

@ -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) domrfcad_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,

View file

@ -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 /

View file

@ -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)

View file

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

View file

@ -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")

View file

@ -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) domrfcad_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;