feat(site-finder): wire domrf→cad_buildings geom-match + parking_ratio конкурентов (#96)
Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Failing after 2m5s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m35s
CI / backend-tests (push) Successful in 9m0s
CI / backend-tests (pull_request) Successful in 8m43s
Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Failing after 2m5s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m35s
CI / backend-tests (push) Successful in 9m0s
CI / backend-tests (pull_request) Successful in 8m43s
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
This commit is contained in:
parent
2c22c3f7ea
commit
7e2065a74f
5 changed files with 601 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal file
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue