feat(site-finder): domrf→cad geom-match + parking_ratio конкурентов (#96) #1327
6 changed files with 704 additions and 2 deletions
|
|
@ -22,6 +22,7 @@ from app.schemas.parcel import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
BestLayoutsRequest,
|
BestLayoutsRequest,
|
||||||
BestLayoutsResponse,
|
BestLayoutsResponse,
|
||||||
|
CompetitorsParkingResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
CompetitorsResponse,
|
CompetitorsResponse,
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
|
|
@ -58,7 +59,7 @@ from app.services.site_finder.cadastre_fetch import (
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
fetch_status as _fetch_status,
|
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 (
|
from app.services.site_finder.custom_pois import (
|
||||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||||||
)
|
)
|
||||||
|
|
@ -3151,6 +3152,36 @@ async def get_parcel_competitors(
|
||||||
) from exc
|
) 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)
|
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
|
||||||
async def get_parcel_best_layouts(
|
async def get_parcel_best_layouts(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,43 @@ class CompetitorsResponse(BaseModel):
|
||||||
summary: CompetitorsSummary
|
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-форсайт») ─────────────────────────────────
|
# ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
|
||||||
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
||||||
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,27 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
Competitor,
|
Competitor,
|
||||||
|
CompetitorParking,
|
||||||
|
CompetitorsParkingResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
CompetitorsResponse,
|
CompetitorsResponse,
|
||||||
CompetitorsSummary,
|
CompetitorsSummary,
|
||||||
)
|
)
|
||||||
from app.services.forecast_request_cache import cached
|
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__)
|
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, ...]:
|
def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]:
|
||||||
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
|
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
|
||||||
|
|
@ -679,3 +692,80 @@ def get_competitors(
|
||||||
)
|
)
|
||||||
|
|
||||||
return CompetitorsResponse(competitors=competitors, summary=summary)
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.nspd_bulk import ObjectsListing
|
from app.schemas.nspd_bulk import ObjectsListing
|
||||||
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
||||||
|
|
@ -41,6 +45,123 @@ logger = logging.getLogger(__name__)
|
||||||
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
||||||
_THEMATIC_BUILDING = 5
|
_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:
|
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
|
||||||
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
||||||
|
|
@ -127,4 +248,38 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None:
|
||||||
return listing
|
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")
|
||||||
|
|
@ -409,6 +409,36 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/parcels/{cad_num}/best-layouts": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3049,6 +3079,44 @@ export interface components {
|
||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
} | null;
|
} | 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 */
|
||||||
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: {
|
get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue