fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс #2531
19 changed files with 1717 additions and 1111 deletions
|
|
@ -19,60 +19,110 @@ logger = logging.getLogger(__name__)
|
||||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||||
|
|
||||||
# Маппинг OSM-тег → нормализованная category
|
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||||
OSM_CATEGORIES: dict[tuple[str, str], str] = {
|
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
||||||
|
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
||||||
|
# уровне отдельных HTTP-запросов: элемент, подходящий под любую из альтернативных схем
|
||||||
|
# разметки, попадёт в категорию.
|
||||||
|
OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
||||||
# amenity tags — школы расширены (school/college/university)
|
# amenity tags — школы расширены (school/college/university)
|
||||||
("amenity", "school"): "school",
|
(("amenity", "school"),): "school",
|
||||||
("amenity", "college"): "school",
|
(("amenity", "college"),): "school",
|
||||||
("amenity", "university"): "school",
|
(("amenity", "university"),): "school",
|
||||||
("amenity", "kindergarten"): "kindergarten",
|
(("amenity", "kindergarten"),): "kindergarten",
|
||||||
("amenity", "pharmacy"): "pharmacy",
|
(("amenity", "pharmacy"),): "pharmacy",
|
||||||
("amenity", "hospital"): "hospital",
|
(("amenity", "hospital"),): "hospital",
|
||||||
("amenity", "clinic"): "hospital",
|
(("amenity", "clinic"),): "hospital",
|
||||||
# shop tags — supermarket расширен
|
# shop tags — supermarket расширен
|
||||||
("shop", "mall"): "shop_mall",
|
(("shop", "mall"),): "shop_mall",
|
||||||
("shop", "supermarket"): "shop_supermarket",
|
(("shop", "supermarket"),): "shop_supermarket",
|
||||||
("shop", "hypermarket"): "shop_supermarket",
|
(("shop", "hypermarket"),): "shop_supermarket",
|
||||||
("shop", "convenience"): "shop_small",
|
(("shop", "convenience"),): "shop_small",
|
||||||
("shop", "bakery"): "shop_small",
|
(("shop", "bakery"),): "shop_small",
|
||||||
# leisure
|
# leisure
|
||||||
("leisure", "park"): "park",
|
(("leisure", "park"),): "park",
|
||||||
# transit
|
# transit
|
||||||
("railway", "tram_stop"): "tram_stop",
|
(("railway", "tram_stop"),): "tram_stop",
|
||||||
("highway", "bus_stop"): "bus_stop",
|
(("highway", "bus_stop"),): "bus_stop",
|
||||||
# метро (одна линия в ЕКБ, но добавляем для полноты)
|
# Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил
|
||||||
("station", "subway"): "metro_stop",
|
# ТОЛЬКО station=subway и подтягивал лишь 5/9 станций — часть станций в OSM размечена
|
||||||
|
# без ключа "station" вовсе, комбинацией railway=station + subway=yes (альтернативная,
|
||||||
|
# но распространённая схема разметки метро). Обе схемы — отдельными записями ниже, чтобы
|
||||||
|
# не терять станции, размеченные любой из них.
|
||||||
|
(("station", "subway"),): "metro_stop",
|
||||||
|
(("railway", "station"), ("subway", "yes")): "metro_stop",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_overpass_query_single(key: str, value: str) -> str:
|
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
"""Запрос для одной пары tag → нормированной категории.
|
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||||
|
|
||||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||||
14 быстрых запросов вместо одного 60+ секундного.
|
быстрые запросы вместо одного 60+ секундного.
|
||||||
"""
|
"""
|
||||||
south, west, north, east = EKB_BBOX
|
south, west, north, east = EKB_BBOX
|
||||||
bbox = f"({south},{west},{north},{east})"
|
bbox = f"({south},{west},{north},{east})"
|
||||||
return (
|
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||||
f"[out:json][timeout:30];"
|
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||||
f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);'
|
|
||||||
f"out center meta;"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _classify(tags: dict[str, str]) -> str | None:
|
def _classify(tags: dict[str, str]) -> str | None:
|
||||||
"""Определить category из OSM-тегов. None если не соответствует ни одной."""
|
"""Определить category из OSM-тегов. None если не соответствует ни одной."""
|
||||||
for (k, v), cat in OSM_CATEGORIES.items():
|
for tag_filters, cat in OSM_CATEGORIES.items():
|
||||||
if tags.get(k) == v:
|
if all(tags.get(k) == v for k, v in tag_filters):
|
||||||
return cat
|
return cat
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
return ",".join(f"{k}={v}" for k, v in tag_filters)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_category(
|
||||||
|
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
|
||||||
|
|
||||||
|
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
||||||
|
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
||||||
|
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
||||||
|
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
||||||
|
2 попыток на категорию).
|
||||||
|
"""
|
||||||
|
tag_desc = _tag_filters_desc(tag_filters)
|
||||||
|
query = _build_overpass_query(tag_filters)
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||||
|
r.raise_for_status()
|
||||||
|
elements: list[dict] = r.json().get("elements", [])
|
||||||
|
logger.info(
|
||||||
|
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
||||||
|
)
|
||||||
|
# Привязываем category именно к тому per-category запросу, под который
|
||||||
|
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||||
|
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||||
|
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||||
|
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||||
|
for el in elements:
|
||||||
|
el["_gd_category"] = category
|
||||||
|
return elements
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == 1:
|
||||||
|
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
||||||
|
await asyncio.sleep(3.0)
|
||||||
|
continue
|
||||||
|
logger.warning(
|
||||||
|
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def fetch_overpass() -> list[dict]:
|
async def fetch_overpass() -> list[dict]:
|
||||||
"""Запросить Overpass API per category, вернуть combined список elements.
|
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||||
|
|
||||||
Делаем 14 отдельных запросов вместо одного гигантского — большой запрос
|
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||||
policy: max 2 concurrent, лучше 1 req/s).
|
policy: max 2 concurrent, лучше 1 req/s).
|
||||||
|
|
||||||
|
|
@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]:
|
||||||
}
|
}
|
||||||
all_elements: list[dict] = []
|
all_elements: list[dict] = []
|
||||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||||
for (key, value), category in OSM_CATEGORIES.items():
|
for tag_filters, category in OSM_CATEGORIES.items():
|
||||||
query = _build_overpass_query_single(key, value)
|
elements = await _fetch_category(client, tag_filters, category)
|
||||||
try:
|
all_elements.extend(elements)
|
||||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
|
||||||
r.raise_for_status()
|
|
||||||
elements: list[dict] = r.json().get("elements", [])
|
|
||||||
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
|
|
||||||
# Привязываем category именно к тому per-category запросу, под который
|
|
||||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
|
||||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
|
||||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
|
||||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
|
||||||
for el in elements:
|
|
||||||
el["_gd_category"] = category
|
|
||||||
all_elements.extend(elements)
|
|
||||||
except Exception as e:
|
|
||||||
# Не падаем на одной категории — логируем и продолжаем
|
|
||||||
logger.warning("Overpass failed for %s=%s: %s", key, value, e)
|
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass: total %d elements across %d categories", len(all_elements), len(OSM_CATEGORIES)
|
"Overpass: total %d elements across %d category-queries",
|
||||||
|
len(all_elements),
|
||||||
|
len(OSM_CATEGORIES),
|
||||||
)
|
)
|
||||||
return all_elements
|
return all_elements
|
||||||
|
|
||||||
|
|
|
||||||
48
data/sql/188_tradein_osm_poi_view_relax_freshness.sql
Normal file
48
data/sql/188_tradein_osm_poi_view_relax_freshness.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- 188_tradein_osm_poi_view_relax_freshness.sql
|
||||||
|
-- Fix for 185_tradein_osm_poi_view.sql: v_tradein_osm_poi_ekb enforced a HARD 2-year
|
||||||
|
-- OSM last-edit-date filter that silently dropped legitimate, stable infrastructure.
|
||||||
|
--
|
||||||
|
-- CONTEXT (trade-in location-index rework, replaces the broken location-coef):
|
||||||
|
-- Audit of the trade-in POI mirror (osm_poi_ekb_local, fed by this view via the FDW
|
||||||
|
-- bridge) found only 5 of 9 EKB metro stations and just 2787 POI total reaching
|
||||||
|
-- tradein-mvp, despite osm_poi_ekb (this table, Site Finder's own registry) having more.
|
||||||
|
--
|
||||||
|
-- Root cause: this view's WHERE clause dropped any POI whose OSM `last_osm_edit_date` is
|
||||||
|
-- older than 2 years. A subway station node, once correctly mapped, is essentially never
|
||||||
|
-- re-edited in OSM — "stale last edit" here means "nobody touched this tag in years",
|
||||||
|
-- NOT "this station stopped existing". The same logic applies to schools/hospitals/parks:
|
||||||
|
-- physically permanent infrastructure that simply isn't re-edited often.
|
||||||
|
--
|
||||||
|
-- The "2-year freshness" requirement itself (see 82_osm_poi_ekb.sql, "требование
|
||||||
|
-- Максима") was intended as a SOFT confidence signal, not a hard existence filter — Site
|
||||||
|
-- Finder itself (backend/app/api/v1/parcels.py, "POI freshness" confidence subscore) only
|
||||||
|
-- uses last_osm_edit_date to DERATE a confidence score; every POI stays in the result set
|
||||||
|
-- regardless of staleness. This view diverged into a hard filter when the FDW bridge was
|
||||||
|
-- built (185) — this migration fixes that divergence, matching Site Finder's own intent.
|
||||||
|
--
|
||||||
|
-- WHAT: CREATE OR REPLACE VIEW, same 4-column shape as 185 (category, name, lat, lon) — no
|
||||||
|
-- WHERE clause. tradein-mvp's FDW foreign table (tradein-mvp/backend/data/sql/
|
||||||
|
-- 168_fdw_osm_poi_ekb.sql) is untouched — same column list, so no FDW-side change needed.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE OR REPLACE VIEW. GRANT re-applied (idempotent, matches 185).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_tradein_osm_poi_ekb AS
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
FROM osm_poi_ekb;
|
||||||
|
|
||||||
|
GRANT SELECT ON v_tradein_osm_poi_ekb TO tradein_fdw_reader;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_tradein_osm_poi_ekb IS
|
||||||
|
'FDW source for tradein-mvp (postgres_fdw) location-index/nearby-POI list (replaces the '
|
||||||
|
'broken location-coef, #2045). No freshness filter — last_osm_edit_date is a soft '
|
||||||
|
'confidence signal only (see Site Finder parcels.py), not evidence a POI stopped '
|
||||||
|
'existing. Fixes 185_tradein_osm_poi_view.sql hard 2-year WHERE filter that silently '
|
||||||
|
'dropped 4/9 EKB metro stations + other stable infrastructure from the trade-in mirror.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -28,8 +28,8 @@ from app.schemas.trade_in import (
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
LocationCoefFactorOut,
|
LocationIndexResponse,
|
||||||
LocationCoefResponse,
|
NearbyPoiOut,
|
||||||
PhotoMeta,
|
PhotoMeta,
|
||||||
PlacementHistoryEntry,
|
PlacementHistoryEntry,
|
||||||
PriceHistoryYearPoint,
|
PriceHistoryYearPoint,
|
||||||
|
|
@ -1567,39 +1567,43 @@ def get_estimate_imv_benchmark(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Location-coef POI scoring (#2045 BE-3, LocationDrawer) ────────────────────
|
# ── Location index (issue TBD, замена сломанного location-coef #2045) ────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/location-coef", response_model=LocationCoefResponse)
|
@router.get("/location-index", response_model=LocationIndexResponse)
|
||||||
def get_location_coef(
|
def get_location_index(
|
||||||
estimate_id: UUID,
|
estimate_id: UUID,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
radius_m: int | None = None,
|
radius_m: int | None = None,
|
||||||
) -> LocationCoefResponse:
|
) -> LocationIndexResponse:
|
||||||
"""Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer).
|
"""Location index для оценки (замена сломанного location-coef, LocationDrawer).
|
||||||
|
|
||||||
Резолвит lat/lon/median_price оценки, считает coef через
|
Резолвит lat/lon оценки, считает индекс через
|
||||||
app.services.location_coef.compute_location_coef — straight-line POI weighted score
|
app.services.location_index.compute_location_index: % отклонения медианы ₽/м²
|
||||||
(портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local,
|
сопоставимых активных листингов в радиусе точки от медианы ₽/м² по всему Екатеринбургу
|
||||||
обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round(
|
(percentile_cont(0.5) — устойчиво к выбросам). НЕ участвует в цене — estimator.py про
|
||||||
base_price_rub * coef).
|
этот показатель не знает (аналоги уже несут локацию в базовой цене).
|
||||||
|
|
||||||
404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
|
404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
|
||||||
derived-роутов). radius_m опционален (None → DEFAULT_RADIUS_M=1200м, подобран для
|
derived-роутов). radius_m опционален (None → адаптивная лестница радиусов
|
||||||
МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000].
|
RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
|
||||||
|
клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).
|
||||||
|
|
||||||
Graceful fallback (НЕ 500, НЕ сфабрикованные факторы):
|
Честная деградация (НЕ 500, НЕ сфабрикованные значения) — см. LocationIndexResponse:
|
||||||
- osm_poi_ekb_local пуста/не отрефрешена на этом окружении → coef=1.0, factors=[],
|
- status="out_of_coverage" — у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
|
||||||
geo_source="unavailable" (см. compute_location_coef).
|
(Екатеринбург).
|
||||||
- у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) → тот же fallback.
|
- status="insufficient_data" — даже на максимальном радиусе сопоставимых активных
|
||||||
|
листингов меньше порога.
|
||||||
|
- poi_status="unavailable" — osm_poi_ekb_local пуста/не отрефрешена (независимо от
|
||||||
|
status выше — «что рядом» и числовой индекс деградируют раздельно).
|
||||||
"""
|
"""
|
||||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT lat, lon, median_price
|
SELECT lat, lon
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
"""
|
"""
|
||||||
|
|
@ -1609,34 +1613,38 @@ def get_location_coef(
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="estimate not found")
|
raise HTTPException(status_code=404, detail="estimate not found")
|
||||||
|
|
||||||
base_price_rub = int(row.median_price or 0)
|
|
||||||
|
|
||||||
if row.lat is None or row.lon is None:
|
if row.lat is None or row.lon is None:
|
||||||
logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id)
|
logger.info(
|
||||||
return LocationCoefResponse(
|
"location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
|
||||||
coef=1.0,
|
)
|
||||||
factors=[],
|
return LocationIndexResponse(
|
||||||
geo_source="unavailable",
|
status="out_of_coverage",
|
||||||
base_price_rub=base_price_rub,
|
location_index_pct=None,
|
||||||
result_price_rub=base_price_rub,
|
local_median_price_per_m2=None,
|
||||||
|
city_median_price_per_m2=None,
|
||||||
|
sample_size=0,
|
||||||
|
radius_m=radius_m or 0,
|
||||||
|
nearby_poi=[],
|
||||||
|
poi_status="unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.location_coef import DEFAULT_RADIUS_M, compute_location_coef
|
from app.services.location_index import compute_location_index
|
||||||
|
|
||||||
resolved_radius = DEFAULT_RADIUS_M if radius_m is None else max(500, min(radius_m, 3000))
|
resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
|
||||||
result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
||||||
|
|
||||||
return LocationCoefResponse(
|
return LocationIndexResponse(
|
||||||
coef=result.coef,
|
status=result.status,
|
||||||
factors=[
|
location_index_pct=result.location_index_pct,
|
||||||
LocationCoefFactorOut(
|
local_median_price_per_m2=result.local_median_price_per_m2,
|
||||||
poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight
|
city_median_price_per_m2=result.city_median_price_per_m2,
|
||||||
)
|
sample_size=result.sample_size,
|
||||||
for f in result.factors
|
radius_m=result.radius_m,
|
||||||
|
nearby_poi=[
|
||||||
|
NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m)
|
||||||
|
for p in result.nearby_poi
|
||||||
],
|
],
|
||||||
geo_source=result.geo_source,
|
poi_status=result.poi_status,
|
||||||
base_price_rub=base_price_rub,
|
|
||||||
result_price_rub=round(base_price_rub * result.coef),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -598,29 +598,48 @@ class QuotaStatus(BaseModel):
|
||||||
unlimited: bool # True для admin / kopylov / без заголовка
|
unlimited: bool # True для admin / kopylov / без заголовка
|
||||||
|
|
||||||
|
|
||||||
class LocationCoefFactorOut(BaseModel):
|
class NearbyPoiOut(BaseModel):
|
||||||
"""Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3)."""
|
"""Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
|
||||||
|
|
||||||
|
Качественная справка (школа 185 м, остановка 93 м) — НЕ участвует в location_index_pct.
|
||||||
|
"""
|
||||||
|
|
||||||
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
|
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
|
||||||
# что в osm_poi_ekb на стороне gendesign)
|
# что в osm_poi_ekb на стороне gendesign)
|
||||||
name: str | None
|
name: str | None
|
||||||
distance_m: float
|
distance_m: float
|
||||||
weight: float
|
|
||||||
|
|
||||||
|
|
||||||
class LocationCoefResponse(BaseModel):
|
class LocationIndexResponse(BaseModel):
|
||||||
"""Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
|
"""Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
|
||||||
|
|
||||||
coef — MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см.
|
ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
|
||||||
app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05].
|
`result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах — 67% из
|
||||||
result_price_rub = round(base_price_rub * coef).
|
1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы ₽/м² по
|
||||||
|
4000 активным лотам (дороже — не значит выше coef). Полностью заменён.
|
||||||
|
|
||||||
geo_source="unavailable" — osm_poi_ekb_local пуста/не отрефрешена на этом окружении
|
location_index_pct — % отклонения медианы ₽/м² сопоставимых активных листингов в радиусе
|
||||||
(graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы).
|
точки от медианы ₽/м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ
|
||||||
|
зажат искусственно — диапазон реальный. НЕ участвует в цене (estimator.py про него не
|
||||||
|
знает: аналоги уже несут локацию в базовой цене, повторное умножение — double-count).
|
||||||
|
|
||||||
|
status:
|
||||||
|
- "ok" — location_index_pct/local_median_price_per_m2 надёжны.
|
||||||
|
- "out_of_coverage" — точка вне гео-охвата продукта (Екатеринбург). Все числовые
|
||||||
|
поля None — честный прочерк на фронте, НЕ 0%.
|
||||||
|
- "insufficient_data" — даже на максимальном радиусе сопоставимых активных листингов
|
||||||
|
меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m
|
||||||
|
показывают, что реально нашлось.
|
||||||
|
|
||||||
|
poi_status — независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local
|
||||||
|
ещё не отрефрешена на этом окружении — пустой список, НЕ сфабрикованные точки).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
coef: float
|
status: str
|
||||||
factors: list[LocationCoefFactorOut]
|
location_index_pct: float | None
|
||||||
geo_source: str
|
local_median_price_per_m2: int | None
|
||||||
base_price_rub: int
|
city_median_price_per_m2: int | None
|
||||||
result_price_rub: int
|
sample_size: int
|
||||||
|
radius_m: int
|
||||||
|
nearby_poi: list[NearbyPoiOut]
|
||||||
|
poi_status: str
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer).
|
|
||||||
|
|
||||||
Ported straight-line formula from Site Finder (ПТИЦА)
|
|
||||||
`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`:
|
|
||||||
|
|
||||||
weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category]
|
|
||||||
|
|
||||||
Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by
|
|
||||||
`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW — see migrations
|
|
||||||
168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost
|
|
||||||
measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the
|
|
||||||
remote) would make a synchronous endpoint unusable.
|
|
||||||
|
|
||||||
Scope (MVP, #2045 BE-3):
|
|
||||||
- Straight-line distance only. The ORS routing-decay mode from Site Finder
|
|
||||||
(`compute_poi_routing_decay`) is NOT ported — no ORS infrastructure in trade-in, out of
|
|
||||||
MVP scope.
|
|
||||||
- Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels.
|
|
||||||
- The score→coef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site
|
|
||||||
Finder (there POI score is a ranking metric, not a price multiplier) — see its docstring.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS
|
|
||||||
# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован
|
|
||||||
# между продуктами.
|
|
||||||
CATEGORY_WEIGHTS: dict[str, float] = {
|
|
||||||
"metro_stop": 6.0,
|
|
||||||
"school": 5.0,
|
|
||||||
"kindergarten": 4.5,
|
|
||||||
"hospital": 4.0,
|
|
||||||
"shop_mall": 4.0,
|
|
||||||
"shop_supermarket": 3.5,
|
|
||||||
"bus_stop": 4.5,
|
|
||||||
"park": 3.5,
|
|
||||||
"pharmacy": 2.5,
|
|
||||||
"tram_stop": 2.0,
|
|
||||||
"shop_small": 2.0,
|
|
||||||
"default": 1.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) —
|
|
||||||
# пешая доступность в пределах квартала/микрорайона.
|
|
||||||
DEFAULT_RADIUS_M = 1200
|
|
||||||
DEFAULT_TOP_N = 7
|
|
||||||
|
|
||||||
# Теоретический максимум суммы весов top-7 POI.
|
|
||||||
#
|
|
||||||
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
|
|
||||||
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
|
|
||||||
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
|
|
||||||
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
|
|
||||||
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
|
|
||||||
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
|
|
||||||
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
|
|
||||||
#
|
|
||||||
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
|
|
||||||
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
|
|
||||||
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
|
|
||||||
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
|
|
||||||
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
|
|
||||||
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
|
|
||||||
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
|
|
||||||
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
|
|
||||||
#
|
|
||||||
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
|
|
||||||
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
|
|
||||||
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
|
|
||||||
#
|
|
||||||
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
|
|
||||||
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
|
||||||
_REF_DISTANCE_M: float = 100.0
|
|
||||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
|
|
||||||
_REF_DISTANCE_M + 100.0
|
|
||||||
) # ≈ 0.1575 (было 0.315 при d=0)
|
|
||||||
|
|
||||||
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
|
|
||||||
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
|
|
||||||
_COEF_BASE = 0.95
|
|
||||||
_COEF_SPREAD = 0.10
|
|
||||||
|
|
||||||
|
|
||||||
def _category_weight(category: str | None) -> float:
|
|
||||||
"""Вернуть вес категории. Если не знаем — default."""
|
|
||||||
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
|
|
||||||
|
|
||||||
|
|
||||||
class LocationCoefFactor(BaseModel):
|
|
||||||
"""Один POI-фактор в ответе location-coef."""
|
|
||||||
|
|
||||||
poi_type: str
|
|
||||||
name: str | None
|
|
||||||
distance_m: float
|
|
||||||
weight: float
|
|
||||||
|
|
||||||
|
|
||||||
class LocationCoefResult(BaseModel):
|
|
||||||
"""Результат compute_location_coef — потребляется эндпоинтом location-coef."""
|
|
||||||
|
|
||||||
coef: float
|
|
||||||
factors: list[LocationCoefFactor]
|
|
||||||
geo_source: str # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_to_coef(poi_weighted_score: float) -> float:
|
|
||||||
"""MVP-эвристика score(0..100) → ценовой коэффициент.
|
|
||||||
|
|
||||||
coef = 0.95 + (score/100) * 0.10 → диапазон [0.95, 1.05].
|
|
||||||
|
|
||||||
ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула — первая рабочая
|
|
||||||
эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую
|
|
||||||
метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой,
|
|
||||||
поэтому маппинг введён отдельно и явно помечен как heuristic v1.
|
|
||||||
"""
|
|
||||||
return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4)
|
|
||||||
|
|
||||||
|
|
||||||
_NEAREST_POI_SQL = text(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
p.name,
|
|
||||||
p.category,
|
|
||||||
CAST(
|
|
||||||
ST_Distance(
|
|
||||||
p.geom::geography,
|
|
||||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
|
|
||||||
) AS double precision
|
|
||||||
) AS distance_m
|
|
||||||
FROM osm_poi_ekb_local p
|
|
||||||
WHERE p.geom IS NOT NULL
|
|
||||||
AND ST_DWithin(
|
|
||||||
p.geom::geography,
|
|
||||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
|
||||||
CAST(:radius_m AS double precision)
|
|
||||||
)
|
|
||||||
ORDER BY distance_m ASC
|
|
||||||
LIMIT :limit
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_location_coef(
|
|
||||||
db: Any,
|
|
||||||
lat: float,
|
|
||||||
lon: float,
|
|
||||||
radius_m: int = DEFAULT_RADIUS_M,
|
|
||||||
top_n: int = DEFAULT_TOP_N,
|
|
||||||
) -> LocationCoefResult:
|
|
||||||
"""Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local.
|
|
||||||
|
|
||||||
Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом
|
|
||||||
окружении) — возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или
|
|
||||||
сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы — это легитимный
|
|
||||||
результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: SQLAlchemy Session.
|
|
||||||
lat: широта целевой квартиры.
|
|
||||||
lon: долгота целевой квартиры.
|
|
||||||
radius_m: радиус поиска в метрах (default 1200 — подобран для МКД, не для участков).
|
|
||||||
top_n: количество POI, учитываемых в score (default 7).
|
|
||||||
"""
|
|
||||||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
|
||||||
if total == 0:
|
|
||||||
logger.warning(
|
|
||||||
"location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
|
||||||
"environment) — returning unavailable fallback, no fabricated factors"
|
|
||||||
)
|
|
||||||
return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable")
|
|
||||||
|
|
||||||
rows = (
|
|
||||||
db.execute(
|
|
||||||
_NEAREST_POI_SQL,
|
|
||||||
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
scored: list[tuple[float, LocationCoefFactor]] = []
|
|
||||||
for row in rows:
|
|
||||||
distance_m = float(row["distance_m"])
|
|
||||||
category = row["category"] or "default"
|
|
||||||
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
|
|
||||||
scored.append(
|
|
||||||
(
|
|
||||||
weight,
|
|
||||||
LocationCoefFactor(
|
|
||||||
poi_type=category,
|
|
||||||
name=row["name"],
|
|
||||||
distance_m=round(distance_m, 1),
|
|
||||||
weight=round(weight, 6),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
scored.sort(key=lambda pair: pair[0], reverse=True)
|
|
||||||
top_factors = [factor for _weight, factor in scored[:top_n]]
|
|
||||||
|
|
||||||
raw_sum = sum(factor.weight for factor in top_factors)
|
|
||||||
poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0)
|
|
||||||
coef = _score_to_coef(poi_weighted_score)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f",
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
radius_m,
|
|
||||||
len(rows),
|
|
||||||
len(top_factors),
|
|
||||||
poi_weighted_score,
|
|
||||||
coef,
|
|
||||||
)
|
|
||||||
|
|
||||||
return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb")
|
|
||||||
436
tradein-mvp/backend/app/services/location_index.py
Normal file
436
tradein-mvp/backend/app/services/location_index.py
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
"""Location index for trade-in estimates — replaces the broken `location_coef` (LocationDrawer).
|
||||||
|
|
||||||
|
ИСТОРИЯ / ПОЧЕМУ ПЕРЕПИСАНО:
|
||||||
|
Старый `location_coef.py` считал `coef = 0.95 + (poi_weighted_score/100) * 0.10` — диапазон
|
||||||
|
жёстко зажат в [0.95, 1.05], без какой-либо калибровки на реальных ценах. Аудит на боевой БД
|
||||||
|
(1500 адресов ЕКБ + 4000 активных лотов) показал:
|
||||||
|
- 67% адресов попадали в −1%…+1%, у ~25% coef был РОВНО 1.0 (score=50) — почти
|
||||||
|
неинформативно, весь город умещался в −4%…+5%;
|
||||||
|
- связи с ценой не было вообще: медиана ₽/м² по бакетам coef плоская и НЕ монотонна
|
||||||
|
(бакет −4% дороже бакета +3%).
|
||||||
|
Для сравнения, расстояние до центра ЕКБ на 31 тыс. лотов даёт чистый монотонный градиент
|
||||||
|
(0-2км 249 686 ₽/м² → 12-13км 93 677 ₽/м², разброс 2.7×) — сигнал в данных есть, просто
|
||||||
|
POI-score его не улавливал (POI ranking ≠ цена).
|
||||||
|
|
||||||
|
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||||
|
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||||
|
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||||
|
|
||||||
|
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||||
|
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||||
|
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||||
|
искусственно — если район реально на 40% дороже, так и покажет.
|
||||||
|
|
||||||
|
ЧЕСТНАЯ ДЕГРАДАЦИЯ (см. LocationIndexResult.status):
|
||||||
|
- "out_of_coverage" — точка вне гео-охвата продукта (bbox Екатеринбурга). НЕ 0%, НЕ
|
||||||
|
fallback-число — прочерк на фронте.
|
||||||
|
- "insufficient_data" — даже на максимальном радиусе выборки < MIN_SAMPLE_SIZE сопоставимых
|
||||||
|
активных листингов. Тоже прочерк, а не шум по 3 объявлениям.
|
||||||
|
- "ok" — index надёжен.
|
||||||
|
|
||||||
|
В ЦЕНУ НЕ ИДЁТ: estimator.py про этот модуль не знает и не должен знать — аналоги уже берутся
|
||||||
|
из того же района (локация учтена в базовой цене через сам подбор сопоставимых объектов),
|
||||||
|
повторное умножение на локационный индекс было бы двойным учётом одного и того же эффекта.
|
||||||
|
|
||||||
|
POI («что рядом» — школа/метро/остановка) сохранены как ОТДЕЛЬНАЯ качественная справка
|
||||||
|
(`nearby_poi`, ранжирование как раньше в location_coef.py), но больше не участвуют в числовом
|
||||||
|
показателе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||||
|
# Тот же bbox, что EKB_BBOX в backend/app/services/site_finder/poi_loader.py (main
|
||||||
|
# gendesign backend, Overpass-загрузчик osm_poi_ekb) и что использовался при аудите
|
||||||
|
# (1500 адресов / 4000 активных лотов / 2787 POI, все — "по Екатеринбургу"). tradein-mvp —
|
||||||
|
# отдельный деплой/venv от backend/, поэтому константа продублирована, не импортирована;
|
||||||
|
# при изменении bbox в одном месте — проверить и второе (комментарий в обе стороны).
|
||||||
|
_EKB_BBOX_SOUTH = 56.70
|
||||||
|
_EKB_BBOX_WEST = 60.50
|
||||||
|
_EKB_BBOX_NORTH = 56.95
|
||||||
|
_EKB_BBOX_EAST = 60.75
|
||||||
|
|
||||||
|
|
||||||
|
def _in_ekb_bbox(lat: float, lon: float) -> bool:
|
||||||
|
"""True если точка внутри гео-охвата продукта (Екатеринбург)."""
|
||||||
|
return _EKB_BBOX_SOUTH <= lat <= _EKB_BBOX_NORTH and _EKB_BBOX_WEST <= lon <= _EKB_BBOX_EAST
|
||||||
|
|
||||||
|
|
||||||
|
# ── Калибровочные константы (радиус / минимальная выборка) ──────────────────
|
||||||
|
#
|
||||||
|
# Плотность-прикидка для обоснования порядка величины (НЕ подтверждено живым запросом к
|
||||||
|
# прод-БД в этом изменении — см. PR description "непроверенное"): ЕКБ-аудит насчитал ~4000
|
||||||
|
# активных лотов в bbox площадью ~ 27.8км (0.25° широты) × 15.3км (0.25° долготы на широте
|
||||||
|
# 56.8°) ≈ 425 км² → плотность ~9.4 лота/км². Круг радиусом 800м имеет площадь ~2.01 км² →
|
||||||
|
# ожидаемо ~19 лотов при равномерной плотности — близко к MIN_SAMPLE_SIZE=20, т.е. стартовый
|
||||||
|
# радиус разумен для "средней" точки. Плотность в городе крайне неравномерна (центр много
|
||||||
|
# гуще окраин) — поэтому лестница радиусов расширяется, а не фиксированный радиус.
|
||||||
|
RADIUS_LADDER_M: tuple[int, ...] = (800, 1500, 2500)
|
||||||
|
|
||||||
|
# Ниже этого числа сопоставимых активных листингов медиана — шум, не показатель.
|
||||||
|
# Порог не откалиброван статистически (например через доверительный интервал медианы) —
|
||||||
|
# первая рабочая оценка для MVP. TODO: перепроверить на реальном распределении выборок по
|
||||||
|
# районам ЕКБ (см. "непроверенное" в отчёте задачи).
|
||||||
|
MIN_SAMPLE_SIZE = 20
|
||||||
|
|
||||||
|
# Санитарные (НЕ бизнес-калибровочные) границы ₽/м² — отсекают заведомо битые скрейп-строки
|
||||||
|
# (парсинг ошибся на порядок и т.п.), не сужают реальный рынок ЕКБ (там диапазон примерно
|
||||||
|
# 40-400 тыс₽/м², с большим запасом по краям).
|
||||||
|
_PRICE_PER_M2_SANITY_MIN = 20_000
|
||||||
|
_PRICE_PER_M2_SANITY_MAX = 1_000_000
|
||||||
|
|
||||||
|
DEFAULT_POI_RADIUS_M = 1200 # как в старом location_coef.py — подобран для МКД
|
||||||
|
DEFAULT_POI_TOP_N = 7
|
||||||
|
|
||||||
|
# Веса по категории POI — те же, что были в location_coef.py (ranking "что рядом",
|
||||||
|
# больше НЕ конвертируются в число, влияющее на индекс).
|
||||||
|
CATEGORY_WEIGHTS: dict[str, float] = {
|
||||||
|
"metro_stop": 6.0,
|
||||||
|
"school": 5.0,
|
||||||
|
"kindergarten": 4.5,
|
||||||
|
"hospital": 4.0,
|
||||||
|
"shop_mall": 4.0,
|
||||||
|
"shop_supermarket": 3.5,
|
||||||
|
"bus_stop": 4.5,
|
||||||
|
"park": 3.5,
|
||||||
|
"pharmacy": 2.5,
|
||||||
|
"tram_stop": 2.0,
|
||||||
|
"shop_small": 2.0,
|
||||||
|
"default": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _category_weight(category: str | None) -> float:
|
||||||
|
"""Вернуть вес категории. Если не знаем — default."""
|
||||||
|
return CATEGORY_WEIGHTS.get(category or "default", CATEGORY_WEIGHTS["default"])
|
||||||
|
|
||||||
|
|
||||||
|
class NearbyPoi(BaseModel):
|
||||||
|
"""Один пункт «что рядом» — качественная справка, НЕ участвует в location_index_pct."""
|
||||||
|
|
||||||
|
poi_type: str
|
||||||
|
name: str | None
|
||||||
|
distance_m: float
|
||||||
|
|
||||||
|
|
||||||
|
class LocationIndexResult(BaseModel):
|
||||||
|
"""Результат compute_location_index — потребляется эндпоинтом location-index."""
|
||||||
|
|
||||||
|
status: str # "ok" | "out_of_coverage" | "insufficient_data"
|
||||||
|
location_index_pct: float | None
|
||||||
|
local_median_price_per_m2: int | None
|
||||||
|
city_median_price_per_m2: int | None
|
||||||
|
sample_size: int
|
||||||
|
radius_m: int
|
||||||
|
nearby_poi: list[NearbyPoi]
|
||||||
|
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
|
"""% отклонения локальной медианы от городской.
|
||||||
|
|
||||||
|
Округление до 1 знака — не создаёт ложной точности (исходные данные — шумные скрейп-цены).
|
||||||
|
"""
|
||||||
|
if city_median_ppm2 <= 0:
|
||||||
|
# Защита от деления на ноль при вырожденной городской выборке — не должно
|
||||||
|
# случаться в проде (там ~4000 активных лотов), только в пустой dev-БД.
|
||||||
|
return 0.0
|
||||||
|
return round((local_median_ppm2 - city_median_ppm2) / city_median_ppm2 * 100.0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQL: медиана ₽/м² сопоставимых активных листингов ────────────────────────
|
||||||
|
#
|
||||||
|
# percentile_cont(0.5) — тот же идиом, что уже используется в estimator.py для медианных
|
||||||
|
# ₽/м² трендов (_fetch_price_trend) — устойчив к выбросам В ОТЛИЧИЕ от AVG/min/max: единичный
|
||||||
|
# аномально дорогой/дешёвый лот не сдвигает медиану заметно.
|
||||||
|
#
|
||||||
|
# geo_precision IS DISTINCT FROM 'city' — тот же фильтр, что в estimator.py (#769 Part E):
|
||||||
|
# исключает листинги с геокодом до центра города (city-centroid fallback без номера дома),
|
||||||
|
# которые иначе "подмешивались" бы в любой радиус вокруг центра.
|
||||||
|
#
|
||||||
|
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||||
|
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||||
|
#
|
||||||
|
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||||
|
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||||
|
# явно ограничил владелец продукта.
|
||||||
|
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||||||
|
AS median_ppm2,
|
||||||
|
count(*) AS n
|
||||||
|
FROM listings
|
||||||
|
WHERE is_active = true
|
||||||
|
AND price_per_m2 IS NOT NULL
|
||||||
|
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||||
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
|
AND CAST(:bbox_north AS double precision)
|
||||||
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
AND CAST(:bbox_east AS double precision)
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||||
|
CAST(:radius_m AS double precision)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS double precision)
|
||||||
|
AS median_ppm2,
|
||||||
|
count(*) AS n
|
||||||
|
FROM listings
|
||||||
|
WHERE is_active = true
|
||||||
|
AND price_per_m2 IS NOT NULL
|
||||||
|
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||||
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
|
AND CAST(:bbox_north AS double precision)
|
||||||
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
AND CAST(:bbox_east AS double precision)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_NEAREST_POI_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.name,
|
||||||
|
p.category,
|
||||||
|
CAST(
|
||||||
|
ST_Distance(
|
||||||
|
p.geom::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
|
||||||
|
) AS double precision
|
||||||
|
) AS distance_m
|
||||||
|
FROM osm_poi_ekb_local p
|
||||||
|
WHERE p.geom IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
p.geom::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||||
|
CAST(:radius_m AS double precision)
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[float | None, int]:
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
_MEDIAN_PPM2_LOCAL_SQL,
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
|
"bbox_south": _EKB_BBOX_SOUTH,
|
||||||
|
"bbox_north": _EKB_BBOX_NORTH,
|
||||||
|
"bbox_west": _EKB_BBOX_WEST,
|
||||||
|
"bbox_east": _EKB_BBOX_EAST,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None, 0
|
||||||
|
median = row["median_ppm2"]
|
||||||
|
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
|
{
|
||||||
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
|
"bbox_south": _EKB_BBOX_SOUTH,
|
||||||
|
"bbox_north": _EKB_BBOX_NORTH,
|
||||||
|
"bbox_west": _EKB_BBOX_WEST,
|
||||||
|
"bbox_east": _EKB_BBOX_EAST,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None, 0
|
||||||
|
median = row["median_ppm2"]
|
||||||
|
return (float(median) if median is not None else None), int(row["n"] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_nearby_poi(
|
||||||
|
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||||||
|
) -> tuple[list[NearbyPoi], str]:
|
||||||
|
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||||
|
|
||||||
|
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||||||
|
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||||||
|
"""
|
||||||
|
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||||
|
if total == 0:
|
||||||
|
logger.warning(
|
||||||
|
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||||
|
"environment) — nearby_poi unavailable, no fabricated factors"
|
||||||
|
)
|
||||||
|
return [], "unavailable"
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
_NEAREST_POI_SQL,
|
||||||
|
{"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked: list[tuple[float, NearbyPoi]] = []
|
||||||
|
for row in rows:
|
||||||
|
distance_m = float(row["distance_m"])
|
||||||
|
category = row["category"] or "default"
|
||||||
|
weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
|
||||||
|
ranked.append(
|
||||||
|
(
|
||||||
|
weight,
|
||||||
|
NearbyPoi(poi_type=category, name=row["name"], distance_m=round(distance_m, 1)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||||||
|
return [poi for _weight, poi in ranked[:top_n]], "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_location_index(
|
||||||
|
db: Any,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
*,
|
||||||
|
radius_m: int | None = None,
|
||||||
|
poi_radius_m: int = DEFAULT_POI_RADIUS_M,
|
||||||
|
poi_top_n: int = DEFAULT_POI_TOP_N,
|
||||||
|
) -> LocationIndexResult:
|
||||||
|
"""Посчитать location index для координат (lat, lon).
|
||||||
|
|
||||||
|
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||||
|
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100. Радиус — лестница RADIUS_LADDER_M
|
||||||
|
(расширяется, пока выборка не наберёт MIN_SAMPLE_SIZE), если явный radius_m не передан
|
||||||
|
(тогда используется РОВНО он, без расширения — для отладки/тестов).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy Session.
|
||||||
|
lat: широта целевой точки.
|
||||||
|
lon: долгота целевой точки.
|
||||||
|
radius_m: явный радиус в метрах — если задан, лестница не используется.
|
||||||
|
poi_radius_m: радиус для качественного списка «что рядом» (независим от индекса).
|
||||||
|
poi_top_n: сколько POI показать в «что рядом».
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LocationIndexResult со status:
|
||||||
|
- "out_of_coverage" — точка вне bbox Екатеринбурга, ничего не считаем.
|
||||||
|
- "insufficient_data" — даже на максимальном радиусе сопоставимых листингов
|
||||||
|
меньше MIN_SAMPLE_SIZE (или городская выборка-эталон сама вырождена).
|
||||||
|
- "ok" — location_index_pct надёжен.
|
||||||
|
"""
|
||||||
|
if not _in_ekb_bbox(lat, lon):
|
||||||
|
logger.info(
|
||||||
|
"location_index: lat=%.5f lon=%.5f outside EKB coverage bbox — out_of_coverage",
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
)
|
||||||
|
return LocationIndexResult(
|
||||||
|
status="out_of_coverage",
|
||||||
|
location_index_pct=None,
|
||||||
|
local_median_price_per_m2=None,
|
||||||
|
city_median_price_per_m2=None,
|
||||||
|
sample_size=0,
|
||||||
|
radius_m=radius_m or RADIUS_LADDER_M[0],
|
||||||
|
nearby_poi=[],
|
||||||
|
poi_status="unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||||
|
|
||||||
|
city_median, city_n = _citywide_median_ppm2(db)
|
||||||
|
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||||
|
logger.warning(
|
||||||
|
"location_index: citywide reference sample too small (n=%d) — insufficient_data",
|
||||||
|
city_n,
|
||||||
|
)
|
||||||
|
return LocationIndexResult(
|
||||||
|
status="insufficient_data",
|
||||||
|
location_index_pct=None,
|
||||||
|
local_median_price_per_m2=None,
|
||||||
|
city_median_price_per_m2=(round(city_median) if city_median is not None else None),
|
||||||
|
sample_size=city_n,
|
||||||
|
radius_m=radius_m or RADIUS_LADDER_M[-1],
|
||||||
|
nearby_poi=nearby_poi,
|
||||||
|
poi_status=poi_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
radii = [radius_m] if radius_m is not None else list(RADIUS_LADDER_M)
|
||||||
|
local_median: float | None = None
|
||||||
|
sample_size = 0
|
||||||
|
used_radius = radii[-1]
|
||||||
|
for r in radii:
|
||||||
|
local_median, sample_size = _local_median_ppm2(db, lat, lon, r)
|
||||||
|
used_radius = r
|
||||||
|
if sample_size >= MIN_SAMPLE_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
if local_median is None or sample_size < MIN_SAMPLE_SIZE:
|
||||||
|
logger.info(
|
||||||
|
"location_index: lat=%.5f lon=%.5f sample=%d < MIN_SAMPLE_SIZE=%d up to "
|
||||||
|
"radius=%dm — insufficient_data",
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
sample_size,
|
||||||
|
MIN_SAMPLE_SIZE,
|
||||||
|
used_radius,
|
||||||
|
)
|
||||||
|
return LocationIndexResult(
|
||||||
|
status="insufficient_data",
|
||||||
|
location_index_pct=None,
|
||||||
|
local_median_price_per_m2=None,
|
||||||
|
city_median_price_per_m2=round(city_median),
|
||||||
|
sample_size=sample_size,
|
||||||
|
radius_m=used_radius,
|
||||||
|
nearby_poi=nearby_poi,
|
||||||
|
poi_status=poi_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
pct = _pct_deviation(local_median, city_median)
|
||||||
|
logger.debug(
|
||||||
|
"location_index: lat=%.5f lon=%.5f radius=%dm n=%d local=%d city=%d pct=%.1f",
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
used_radius,
|
||||||
|
sample_size,
|
||||||
|
round(local_median),
|
||||||
|
round(city_median),
|
||||||
|
pct,
|
||||||
|
)
|
||||||
|
return LocationIndexResult(
|
||||||
|
status="ok",
|
||||||
|
location_index_pct=pct,
|
||||||
|
local_median_price_per_m2=round(local_median),
|
||||||
|
city_median_price_per_m2=round(city_median),
|
||||||
|
sample_size=sample_size,
|
||||||
|
radius_m=used_radius,
|
||||||
|
nearby_poi=nearby_poi,
|
||||||
|
poi_status=poi_status,
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef).
|
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-index).
|
||||||
|
|
||||||
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
|
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
|
||||||
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 — live view of gendesign's
|
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 — live view of gendesign's
|
||||||
|
|
@ -8,9 +8,11 @@ of #2045, already merged + deployed on gendesign).
|
||||||
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW — see
|
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW — see
|
||||||
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
|
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
|
||||||
round-trip (~1.16s/row without a geom index on the remote table) — UNUSABLE for a synchronous
|
round-trip (~1.16s/row without a geom index on the remote table) — UNUSABLE for a synchronous
|
||||||
endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk
|
endpoint (`GET /api/v1/trade-in/location-index`). We materialize the FDW once (single bulk
|
||||||
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
|
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
|
||||||
`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate.
|
`app/services/location_index.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate
|
||||||
|
(nearby-POI qualitative list only — the numeric index itself comes from `listings`, not POI;
|
||||||
|
see that module's docstring for the location-coef → location-index rewrite history).
|
||||||
|
|
||||||
Scheduler source='osm_poi_ekb_refresh' (daily — OSM POI data changes rarely). Pure internal
|
Scheduler source='osm_poi_ekb_refresh' (daily — OSM POI data changes rarely). Pure internal
|
||||||
DB op — one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.
|
DB op — one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.
|
||||||
|
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer).
|
|
||||||
|
|
||||||
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
|
||||||
convention in tests/tasks/test_cadastral_geo_match.py). Covers:
|
|
||||||
- pure functions: _category_weight, _score_to_coef, normalization constants
|
|
||||||
- compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback,
|
|
||||||
no-POI-in-radius (legit zero-score, NOT "unavailable")
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
from app.services import location_coef as lc
|
|
||||||
|
|
||||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_category_weight_known_categories() -> None:
|
|
||||||
assert lc._category_weight("metro_stop") == 6.0
|
|
||||||
assert lc._category_weight("school") == 5.0
|
|
||||||
assert lc._category_weight("kindergarten") == 4.5
|
|
||||||
assert lc._category_weight("hospital") == 4.0
|
|
||||||
assert lc._category_weight("shop_mall") == 4.0
|
|
||||||
assert lc._category_weight("shop_supermarket") == 3.5
|
|
||||||
assert lc._category_weight("bus_stop") == 4.5
|
|
||||||
assert lc._category_weight("park") == 3.5
|
|
||||||
assert lc._category_weight("pharmacy") == 2.5
|
|
||||||
assert lc._category_weight("tram_stop") == 2.0
|
|
||||||
assert lc._category_weight("shop_small") == 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
|
||||||
assert lc._category_weight("unknown_category") == 1.0
|
|
||||||
assert lc._category_weight(None) == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_top7_weight_sum_matches_ptica() -> None:
|
|
||||||
"""Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
|
|
||||||
assert lc._TOP7_WEIGHT_SUM == 31.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
|
|
||||||
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
|
|
||||||
|
|
||||||
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all
|
|
||||||
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
|
|
||||||
strong central address) into the bottom third of the range. The new normalization halves
|
|
||||||
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
|
|
||||||
scores instead.
|
|
||||||
"""
|
|
||||||
assert lc._REF_DISTANCE_M == 100.0
|
|
||||||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_to_coef_bounds() -> None:
|
|
||||||
assert lc._score_to_coef(0.0) == 0.95
|
|
||||||
assert lc._score_to_coef(100.0) == 1.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_to_coef_midpoint() -> None:
|
|
||||||
assert lc._score_to_coef(50.0) == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_to_coef_is_monotonic() -> None:
|
|
||||||
scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0]
|
|
||||||
coefs = [lc._score_to_coef(s) for s in scores]
|
|
||||||
assert coefs == sorted(coefs)
|
|
||||||
|
|
||||||
|
|
||||||
# ── compute_location_coef with a fake DB ─────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResult:
|
|
||||||
def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None):
|
|
||||||
self._scalar_value = scalar_value
|
|
||||||
self._mapping_rows = mapping_rows or []
|
|
||||||
|
|
||||||
def scalar(self) -> Any:
|
|
||||||
return self._scalar_value
|
|
||||||
|
|
||||||
def mappings(self) -> Any:
|
|
||||||
class _Mappings:
|
|
||||||
def __init__(self, rows: list[dict]) -> None:
|
|
||||||
self._rows = rows
|
|
||||||
|
|
||||||
def all(self) -> list[dict]:
|
|
||||||
return self._rows
|
|
||||||
|
|
||||||
return _Mappings(self._mapping_rows)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
|
||||||
"""Minimal Session stand-in: execute() returns queued results in order."""
|
|
||||||
|
|
||||||
def __init__(self, results: list[_FakeResult]) -> None:
|
|
||||||
self._results = list(results)
|
|
||||||
self.executed: list[Any] = []
|
|
||||||
|
|
||||||
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
|
|
||||||
self.executed.append((clause, params))
|
|
||||||
return self._results.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_empty_mirror_returns_unavailable() -> None:
|
|
||||||
"""osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
|
||||||
assert result.coef == 1.0
|
|
||||||
assert result.factors == []
|
|
||||||
assert result.geo_source == "unavailable"
|
|
||||||
# Only the count probe ran — no nearest-POI query issued against an empty mirror.
|
|
||||||
assert len(db.executed) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None:
|
|
||||||
"""Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable."""
|
|
||||||
db = _FakeDB(
|
|
||||||
[
|
|
||||||
_FakeResult(scalar_value=500), # mirror has rows elsewhere
|
|
||||||
_FakeResult(mapping_rows=[]), # nothing near this point
|
|
||||||
]
|
|
||||||
)
|
|
||||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
|
||||||
assert result.factors == []
|
|
||||||
assert result.geo_source == "osm_poi_ekb"
|
|
||||||
assert result.coef == lc._score_to_coef(0.0) == 0.95
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_weights_and_ranks_top_n() -> None:
|
|
||||||
"""Nearer + higher-weight-category POI ranks above farther/lower-weight ones."""
|
|
||||||
rows = [
|
|
||||||
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
|
||||||
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
|
|
||||||
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
|
||||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
|
||||||
]
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
|
|
||||||
|
|
||||||
assert result.geo_source == "osm_poi_ekb"
|
|
||||||
assert len(result.factors) == 4
|
|
||||||
# metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only
|
|
||||||
# marginally — sanity check the ranking is weight-driven, not distance-only.
|
|
||||||
assert result.factors[0].poi_type == "metro_stop"
|
|
||||||
# Weights strictly descending (sorted DESC by weight before slicing to top_n).
|
|
||||||
weights = [f.weight for f in result.factors]
|
|
||||||
assert weights == sorted(weights, reverse=True)
|
|
||||||
# coef must land inside the documented [0.95, 1.05] MVP range.
|
|
||||||
assert 0.95 <= result.coef <= 1.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
|
|
||||||
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
|
|
||||||
|
|
||||||
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
|
|
||||||
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
|
|
||||||
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978
|
|
||||||
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
|
|
||||||
it should land close to 1.0 (within the documented ±0.01 calibration target).
|
|
||||||
"""
|
|
||||||
rows = [
|
|
||||||
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
|
|
||||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
|
|
||||||
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
|
|
||||||
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
|
|
||||||
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
|
|
||||||
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
|
|
||||||
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
|
|
||||||
]
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
|
||||||
|
|
||||||
assert result.geo_source == "osm_poi_ekb"
|
|
||||||
assert len(result.factors) == 7
|
|
||||||
assert abs(result.coef - 1.0) <= 0.01
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
|
|
||||||
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
|
|
||||||
rows = [
|
|
||||||
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
|
|
||||||
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
|
|
||||||
]
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
|
||||||
|
|
||||||
assert result.geo_source == "osm_poi_ekb"
|
|
||||||
assert result.coef < 0.98
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_limits_to_top_n() -> None:
|
|
||||||
"""More than top_n candidates → only top_n factors surface in the response."""
|
|
||||||
rows = [
|
|
||||||
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
|
|
||||||
for i in range(20)
|
|
||||||
]
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
|
|
||||||
assert len(result.factors) == 7
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_unknown_category_uses_default_weight() -> None:
|
|
||||||
rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}]
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)])
|
|
||||||
result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
|
|
||||||
assert len(result.factors) == 1
|
|
||||||
expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"]
|
|
||||||
assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_location_coef_passes_radius_param() -> None:
|
|
||||||
"""radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type)."""
|
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])])
|
|
||||||
lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500)
|
|
||||||
_clause, params = db.executed[1]
|
|
||||||
assert params is not None
|
|
||||||
assert params["radius_m"] == 1500
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
|
||||||
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text))
|
|
||||||
346
tradein-mvp/backend/tests/services/test_location_index.py
Normal file
346
tradein-mvp/backend/tests/services/test_location_index.py
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
"""Unit tests for app.services.location_index (replaces test_location_coef.py).
|
||||||
|
|
||||||
|
No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
|
||||||
|
convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py).
|
||||||
|
Covers:
|
||||||
|
- pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity)
|
||||||
|
- _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module)
|
||||||
|
- compute_location_index: out-of-coverage degradation, insufficient-sample degradation
|
||||||
|
(citywide AND local), radius-ladder expansion, explicit radius_m override, happy path
|
||||||
|
- SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier
|
||||||
|
robustness
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services import location_index as lc
|
||||||
|
|
||||||
|
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||||||
|
_LAT_IN_EKB = 56.838
|
||||||
|
_LON_IN_EKB = 60.605
|
||||||
|
|
||||||
|
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_weight_known_categories() -> None:
|
||||||
|
assert lc._category_weight("metro_stop") == 6.0
|
||||||
|
assert lc._category_weight("school") == 5.0
|
||||||
|
assert lc._category_weight("kindergarten") == 4.5
|
||||||
|
assert lc._category_weight("hospital") == 4.0
|
||||||
|
assert lc._category_weight("shop_mall") == 4.0
|
||||||
|
assert lc._category_weight("shop_supermarket") == 3.5
|
||||||
|
assert lc._category_weight("bus_stop") == 4.5
|
||||||
|
assert lc._category_weight("park") == 3.5
|
||||||
|
assert lc._category_weight("pharmacy") == 2.5
|
||||||
|
assert lc._category_weight("tram_stop") == 2.0
|
||||||
|
assert lc._category_weight("shop_small") == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
||||||
|
assert lc._category_weight("unknown_category") == 1.0
|
||||||
|
assert lc._category_weight(None) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_ekb_bbox_center_is_inside() -> None:
|
||||||
|
assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_ekb_bbox_bounds_are_inclusive() -> None:
|
||||||
|
assert lc._in_ekb_bbox(56.70, 60.50) is True
|
||||||
|
assert lc._in_ekb_bbox(56.95, 60.75) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_ekb_bbox_outside_is_rejected() -> None:
|
||||||
|
# Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox.
|
||||||
|
assert lc._in_ekb_bbox(57.910, 59.970) is False
|
||||||
|
# Just past each edge of the bbox.
|
||||||
|
assert lc._in_ekb_bbox(56.69, 60.60) is False
|
||||||
|
assert lc._in_ekb_bbox(56.96, 60.60) is False
|
||||||
|
assert lc._in_ekb_bbox(56.80, 60.49) is False
|
||||||
|
assert lc._in_ekb_bbox(56.80, 60.76) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_deviation_above_and_below_city_median() -> None:
|
||||||
|
assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0
|
||||||
|
assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0
|
||||||
|
assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_deviation_not_artificially_clamped() -> None:
|
||||||
|
"""Owner requirement: a genuinely +40% district must read as +40%, not clamped."""
|
||||||
|
assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_deviation_guards_zero_division() -> None:
|
||||||
|
assert lc._pct_deviation(100_000.0, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_deviation_is_monotonic_in_local_median() -> None:
|
||||||
|
"""Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие
|
||||||
|
от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring).
|
||||||
|
|
||||||
|
Значения ниже — медианы ₽/м² по дистанционным бакетам от центра ЕКБ, измеренные на 31
|
||||||
|
тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс,
|
||||||
|
построенный на этих же локальных медианах, обязан сохранить порядок.
|
||||||
|
"""
|
||||||
|
city_median = 155_000.0
|
||||||
|
local_medians_ascending = [
|
||||||
|
93_677.0,
|
||||||
|
136_729.0,
|
||||||
|
150_063.0,
|
||||||
|
159_382.0,
|
||||||
|
159_486.0,
|
||||||
|
191_682.0,
|
||||||
|
249_686.0,
|
||||||
|
]
|
||||||
|
pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending]
|
||||||
|
assert pct_values == sorted(pct_values)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQL discipline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
|
"""psycopg v3: never :param::type — must use CAST(:param AS type)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
for sql in (
|
||||||
|
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||||||
|
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
|
lc._NEAREST_POI_SQL,
|
||||||
|
):
|
||||||
|
assert not re.search(r":\w+::", str(sql.text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_median_queries_use_percentile_not_naive_minmax() -> None:
|
||||||
|
"""Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX."""
|
||||||
|
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||||
|
sql_text = str(sql.text).lower()
|
||||||
|
assert "percentile_cont(0.5)" in sql_text
|
||||||
|
assert "avg(" not in sql_text
|
||||||
|
assert "min(" not in sql_text
|
||||||
|
assert "max(" not in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||||||
|
"""Comparable-selection quality control (owner requirement #1): city-centroid geocodes
|
||||||
|
excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox."""
|
||||||
|
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||||
|
sql_text = str(sql.text)
|
||||||
|
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||||||
|
assert "bbox_south" in sql_text and "bbox_north" in sql_text
|
||||||
|
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fetch_nearby_poi (qualitative "что рядом" list) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scalar_value: Any = None,
|
||||||
|
mapping_rows: list[dict] | None = None,
|
||||||
|
mapping_one: dict | None = None,
|
||||||
|
):
|
||||||
|
self._scalar_value = scalar_value
|
||||||
|
self._mapping_rows = mapping_rows or []
|
||||||
|
self._mapping_one = mapping_one
|
||||||
|
|
||||||
|
def scalar(self) -> Any:
|
||||||
|
return self._scalar_value
|
||||||
|
|
||||||
|
def mappings(self) -> Any:
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _Mappings:
|
||||||
|
def all(self) -> list[dict]:
|
||||||
|
return outer._mapping_rows
|
||||||
|
|
||||||
|
def first(self) -> dict | None:
|
||||||
|
return outer._mapping_one
|
||||||
|
|
||||||
|
return _Mappings()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Minimal Session stand-in: execute() returns queued results in order."""
|
||||||
|
|
||||||
|
def __init__(self, results: list[_FakeResult]) -> None:
|
||||||
|
self._results = list(results)
|
||||||
|
self.executed: list[Any] = []
|
||||||
|
|
||||||
|
def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
|
||||||
|
self.executed.append((clause, params))
|
||||||
|
return self._results.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||||
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
|
assert poi == []
|
||||||
|
assert status == "unavailable"
|
||||||
|
assert len(db.executed) == 1 # only the count probe ran
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||||||
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
||||||
|
assert poi == []
|
||||||
|
assert status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||||||
|
rows = [
|
||||||
|
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
||||||
|
{"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
|
||||||
|
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
||||||
|
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||||
|
]
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||||
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
|
assert status == "ok"
|
||||||
|
assert len(poi) == 4
|
||||||
|
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||||||
|
# distance-only ranking.
|
||||||
|
assert poi[0].poi_type == "metro_stop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||||||
|
rows = [
|
||||||
|
{"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
|
||||||
|
for i in range(20)
|
||||||
|
]
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||||
|
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
||||||
|
assert len(poi) == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ── compute_location_index: degradation + ladder logic ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None:
|
||||||
|
"""Owner requirement #2: point outside EKB → honest 'no data', never a fallback number.
|
||||||
|
|
||||||
|
Also a perf/honesty check: no DB round-trip at all for an out-of-scope point.
|
||||||
|
"""
|
||||||
|
db = _FakeDB([])
|
||||||
|
result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil
|
||||||
|
assert result.status == "out_of_coverage"
|
||||||
|
assert result.location_index_pct is None
|
||||||
|
assert result.local_median_price_per_m2 is None
|
||||||
|
assert result.city_median_price_per_m2 is None
|
||||||
|
assert result.sample_size == 0
|
||||||
|
assert result.nearby_poi == []
|
||||||
|
assert result.poi_status == "unavailable"
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None:
|
||||||
|
"""Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever
|
||||||
|
issuing a local-radius query (nothing to compare against anyway)."""
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0), # POI mirror empty
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||||
|
assert result.status == "insufficient_data"
|
||||||
|
assert result.location_index_pct is None
|
||||||
|
assert result.sample_size == 3
|
||||||
|
assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_first_radius_rung_sufficient() -> None:
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0), # poi mirror empty
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.radius_m == lc.RADIUS_LADDER_M[0]
|
||||||
|
assert result.sample_size == 25
|
||||||
|
assert result.local_median_price_per_m2 == 165_000
|
||||||
|
assert result.city_median_price_per_m2 == 150_000
|
||||||
|
assert result.location_index_pct == 10.0
|
||||||
|
assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None:
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.radius_m == lc.RADIUS_LADDER_M[1]
|
||||||
|
assert result.sample_size == 30
|
||||||
|
assert len(db.executed) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_insufficient_even_at_max_radius() -> None:
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||||
|
assert result.status == "insufficient_data"
|
||||||
|
assert result.location_index_pct is None
|
||||||
|
assert result.local_median_price_per_m2 is None
|
||||||
|
assert result.city_median_price_per_m2 == 150_000
|
||||||
|
assert result.radius_m == lc.RADIUS_LADDER_M[-1]
|
||||||
|
assert result.sample_size == 15 # honest: shows how close it got, not just "no data"
|
||||||
|
assert len(db.executed) == 5 # exhausted the full ladder
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_explicit_radius_skips_ladder() -> None:
|
||||||
|
"""An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled)."""
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.radius_m == 1000
|
||||||
|
assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried
|
||||||
|
nearest_call_params = db.executed[2][1]
|
||||||
|
assert nearest_call_params["radius_m"] == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||||||
|
"""poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a
|
||||||
|
perfectly computable price-based index."""
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0), # poi mirror empty
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}),
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.poi_status == "unavailable"
|
||||||
|
assert result.nearby_poi == []
|
||||||
|
assert result.location_index_pct == 15.0
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
"""Tests for GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
|
|
||||||
|
|
||||||
Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / test_street_deals_endpoint.py
|
|
||||||
(no live Postgres needed). Covers:
|
|
||||||
- IDOR guard (owner / other pilot 404 / admin / unauthenticated 401)
|
|
||||||
- graceful fallback: osm_poi_ekb_local empty/not-refreshed, estimate has no lat/lon
|
|
||||||
- happy path: base_price_rub * coef → result_price_rub, factors surfaced
|
|
||||||
- radius_m query-param clamping
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
|
||||||
# (trade_in.py imports generate_trade_in_pdf at module load).
|
|
||||||
_wp_mock = MagicMock()
|
|
||||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
|
||||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
|
||||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
|
||||||
|
|
||||||
import pytest # noqa: E402
|
|
||||||
from fastapi import FastAPI # noqa: E402
|
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
|
||||||
|
|
||||||
_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _restore_get_role():
|
|
||||||
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
|
|
||||||
from app.core import auth as auth_mod
|
|
||||||
|
|
||||||
original = auth_mod.get_role
|
|
||||||
yield
|
|
||||||
auth_mod.get_role = original
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def trade_in_app() -> FastAPI:
|
|
||||||
"""Minimal FastAPI app mounting only the trade-in router."""
|
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
|
||||||
|
|
||||||
application = FastAPI()
|
|
||||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
|
||||||
return application
|
|
||||||
|
|
||||||
|
|
||||||
def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient:
|
|
||||||
"""Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError)."""
|
|
||||||
from app.core.db import get_db
|
|
||||||
|
|
||||||
def _override_db():
|
|
||||||
yield db_mock
|
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = _override_db
|
|
||||||
|
|
||||||
auth_mod = sys.modules["app.core.auth"]
|
|
||||||
if role is None:
|
|
||||||
|
|
||||||
def _raise_keyerror(_u: str):
|
|
||||||
raise KeyError(_u)
|
|
||||||
|
|
||||||
auth_mod.get_role = _raise_keyerror # type: ignore[assignment]
|
|
||||||
else:
|
|
||||||
auth_mod.get_role = lambda _u: role # type: ignore[assignment]
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetchone_result(row: object) -> MagicMock:
|
|
||||||
r = MagicMock()
|
|
||||||
r.fetchone.return_value = row
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar_result(value: object) -> MagicMock:
|
|
||||||
r = MagicMock()
|
|
||||||
r.scalar.return_value = value
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _mapping_result(rows: list[dict]) -> MagicMock:
|
|
||||||
r = MagicMock()
|
|
||||||
r.mappings.return_value.all.return_value = rows
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _db_with(*results: MagicMock) -> MagicMock:
|
|
||||||
db = MagicMock()
|
|
||||||
db.execute.side_effect = list(results)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
# ── IDOR guard ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
|
||||||
"""Non-owner pilot must NOT read someone else's location-coef → 404."""
|
|
||||||
db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim")))
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "attacker"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
|
||||||
"""No X-Authenticated-User header → 401 (guard query already ran, header check fails)."""
|
|
||||||
db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov")))
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None:
|
|
||||||
db = _db_with(_fetchone_result(None))
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "kopylov"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_admin_can_read_any(trade_in_app: FastAPI) -> None:
|
|
||||||
"""Admin reads any estimate's location-coef regardless of owner → 200."""
|
|
||||||
db = _db_with(
|
|
||||||
_fetchone_result(SimpleNamespace(created_by="someone_else")), # guard
|
|
||||||
_fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target
|
|
||||||
)
|
|
||||||
client = _client_with(trade_in_app, db, role="admin")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "admin"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# ── Graceful fallback ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_no_lat_lon_returns_unavailable(trade_in_app: FastAPI) -> None:
|
|
||||||
"""Estimate without lat/lon → unavailable fallback, no osm_poi_ekb_local query at all."""
|
|
||||||
db = _db_with(
|
|
||||||
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
||||||
_fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target
|
|
||||||
)
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "kopylov"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["geo_source"] == "unavailable"
|
|
||||||
assert body["coef"] == 1.0
|
|
||||||
assert body["factors"] == []
|
|
||||||
assert body["base_price_rub"] == 5_000_000
|
|
||||||
assert body["result_price_rub"] == 5_000_000
|
|
||||||
# Short-circuits before compute_location_coef: only guard + target queries ran.
|
|
||||||
assert db.execute.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_empty_mirror_returns_unavailable(trade_in_app: FastAPI) -> None:
|
|
||||||
"""osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
|
|
||||||
db = _db_with(
|
|
||||||
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
||||||
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
||||||
_scalar_result(0), # osm_poi_ekb_local count
|
|
||||||
)
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "kopylov"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["geo_source"] == "unavailable"
|
|
||||||
assert body["coef"] == 1.0
|
|
||||||
assert body["factors"] == []
|
|
||||||
assert body["result_price_rub"] == body["base_price_rub"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Happy path ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_happy_path_computes_result_price(trade_in_app: FastAPI) -> None:
|
|
||||||
"""POI found within radius → coef applied to base_price_rub, factors surfaced."""
|
|
||||||
poi_rows = [
|
|
||||||
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
|
||||||
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
|
||||||
]
|
|
||||||
db = _db_with(
|
|
||||||
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
||||||
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
||||||
_scalar_result(1000), # osm_poi_ekb_local count
|
|
||||||
_mapping_result(poi_rows), # nearest POI
|
|
||||||
)
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID},
|
|
||||||
headers={"X-Authenticated-User": "kopylov"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["geo_source"] == "osm_poi_ekb"
|
|
||||||
assert body["base_price_rub"] == 5_000_000
|
|
||||||
assert 0.95 <= body["coef"] <= 1.05
|
|
||||||
assert body["result_price_rub"] == round(5_000_000 * body["coef"])
|
|
||||||
assert len(body["factors"]) == 2
|
|
||||||
poi_types = {f["poi_type"] for f in body["factors"]}
|
|
||||||
assert poi_types == {"school", "metro_stop"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_location_coef_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None:
|
|
||||||
"""Explicit radius_m outside [500, 3000] is clamped before hitting the DB query."""
|
|
||||||
db = _db_with(
|
|
||||||
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
||||||
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
||||||
_scalar_result(10), # osm_poi_ekb_local count
|
|
||||||
_mapping_result([]), # nearest POI query
|
|
||||||
)
|
|
||||||
client = _client_with(trade_in_app, db, role="pilot")
|
|
||||||
resp = client.get(
|
|
||||||
"/api/v1/trade-in/location-coef",
|
|
||||||
params={"estimate_id": _ESTIMATE_ID, "radius_m": 50},
|
|
||||||
headers={"X-Authenticated-User": "kopylov"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
# 4th execute() call is the nearest-POI query — inspect its bound radius_m param.
|
|
||||||
nearest_call = db.execute.call_args_list[3]
|
|
||||||
params = (
|
|
||||||
nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params")
|
|
||||||
)
|
|
||||||
assert params["radius_m"] == 500
|
|
||||||
325
tradein-mvp/backend/tests/test_location_index_endpoint.py
Normal file
325
tradein-mvp/backend/tests/test_location_index_endpoint.py
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
"""Tests for GET /api/v1/trade-in/location-index (replaces test_location_coef_endpoint.py).
|
||||||
|
|
||||||
|
Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / the deleted
|
||||||
|
test_location_coef_endpoint.py (no live Postgres needed). Covers:
|
||||||
|
- IDOR guard (owner / other pilot 404 / admin / unauthenticated 401)
|
||||||
|
- honest degradation: no lat/lon, point outside EKB coverage, insufficient comparable
|
||||||
|
sample, empty osm_poi_ekb_local mirror (independent of the index status)
|
||||||
|
- happy path: status="ok", location_index_pct computed, nearby_poi surfaced
|
||||||
|
- radius_m query-param clamping
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
||||||
|
# (trade_in.py imports generate_trade_in_pdf at module load).
|
||||||
|
_wp_mock = MagicMock()
|
||||||
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||||
|
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi import FastAPI # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
|
||||||
|
_LAT_IN_EKB = 56.838
|
||||||
|
_LON_IN_EKB = 60.605
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_get_role():
|
||||||
|
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
original = auth_mod.get_role
|
||||||
|
yield
|
||||||
|
auth_mod.get_role = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def trade_in_app() -> FastAPI:
|
||||||
|
"""Minimal FastAPI app mounting only the trade-in router."""
|
||||||
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient:
|
||||||
|
"""Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
def _override_db():
|
||||||
|
yield db_mock
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_db
|
||||||
|
|
||||||
|
auth_mod = sys.modules["app.core.auth"]
|
||||||
|
if role is None:
|
||||||
|
|
||||||
|
def _raise_keyerror(_u: str):
|
||||||
|
raise KeyError(_u)
|
||||||
|
|
||||||
|
auth_mod.get_role = _raise_keyerror # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
auth_mod.get_role = lambda _u: role # type: ignore[assignment]
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetchone_result(row: object) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.fetchone.return_value = row
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar_result(value: object) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.scalar.return_value = value
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_all_result(rows: list[dict]) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.all.return_value = rows
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_one_result(row: dict | None) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.mappings.return_value.first.return_value = row
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _db_with(*results: MagicMock) -> MagicMock:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = list(results)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
# ── IDOR guard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Non-owner pilot must NOT read someone else's location-index → 404."""
|
||||||
|
db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim")))
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "attacker"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
||||||
|
"""No X-Authenticated-User header → 401 (guard query already ran, header check fails)."""
|
||||||
|
db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov")))
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None:
|
||||||
|
db = _db_with(_fetchone_result(None))
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_admin_can_read_any(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Admin reads any estimate's location-index regardless of owner → 200."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="someone_else")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=None, lon=None)), # target — no lat/lon
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="admin")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "admin"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Honest degradation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_no_lat_lon_returns_out_of_coverage(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Estimate without lat/lon → out_of_coverage, no compute_location_index DB call at all."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=None, lon=None)), # target
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "out_of_coverage"
|
||||||
|
assert body["location_index_pct"] is None
|
||||||
|
assert body["nearby_poi"] == []
|
||||||
|
assert body["poi_status"] == "unavailable"
|
||||||
|
# Short-circuits before compute_location_index: only guard + target queries ran.
|
||||||
|
assert db.execute.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_point_outside_ekb_bbox_returns_out_of_coverage(
|
||||||
|
trade_in_app: FastAPI,
|
||||||
|
) -> None:
|
||||||
|
"""Estimate has lat/lon, but outside the EKB coverage bbox (e.g. Nizhny Tagil)."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=57.910, lon=59.970)), # target — Nizhny Tagil
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "out_of_coverage"
|
||||||
|
assert body["location_index_pct"] is None
|
||||||
|
# compute_location_index short-circuits — no further DB calls beyond guard + target.
|
||||||
|
assert db.execute.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_insufficient_sample_returns_no_fabricated_number(
|
||||||
|
trade_in_app: FastAPI,
|
||||||
|
) -> None:
|
||||||
|
"""Comparable sample stays below MIN_SAMPLE_SIZE even at max radius → insufficient_data,
|
||||||
|
never a noisy number computed from a handful of listings."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
|
||||||
|
_scalar_result(0), # osm_poi_ekb_local count
|
||||||
|
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||||
|
_mapping_one_result({"median_ppm2": 200_000.0, "n": 5}), # 800m
|
||||||
|
_mapping_one_result({"median_ppm2": 195_000.0, "n": 12}), # 1500m
|
||||||
|
_mapping_one_result({"median_ppm2": 190_000.0, "n": 15}), # 2500m — still short
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "insufficient_data"
|
||||||
|
assert body["location_index_pct"] is None
|
||||||
|
assert body["local_median_price_per_m2"] is None
|
||||||
|
assert body["sample_size"] == 15
|
||||||
|
assert body["city_median_price_per_m2"] == 150_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_poi_mirror_empty_does_not_block_index(trade_in_app: FastAPI) -> None:
|
||||||
|
"""poi_status="unavailable" is independent of status="ok" — an un-refreshed POI mirror
|
||||||
|
must not prevent a perfectly computable price-based index."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
|
||||||
|
_scalar_result(0), # osm_poi_ekb_local count == 0
|
||||||
|
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||||
|
_mapping_one_result({"median_ppm2": 172_500.0, "n": 40}), # 800m — sufficient
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["location_index_pct"] == 15.0
|
||||||
|
assert body["poi_status"] == "unavailable"
|
||||||
|
assert body["nearby_poi"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Happy path ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_happy_path(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Comparable sample found → index computed, nearby POI surfaced as qualitative info."""
|
||||||
|
poi_rows = [
|
||||||
|
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
||||||
|
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
||||||
|
]
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
|
||||||
|
_scalar_result(1000), # osm_poi_ekb_local count
|
||||||
|
_mapping_all_result(poi_rows), # nearest POI
|
||||||
|
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||||
|
_mapping_one_result({"median_ppm2": 165_000.0, "n": 25}), # 800m — sufficient
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["poi_status"] == "ok"
|
||||||
|
assert body["location_index_pct"] == 10.0
|
||||||
|
assert body["local_median_price_per_m2"] == 165_000
|
||||||
|
assert body["city_median_price_per_m2"] == 150_000
|
||||||
|
assert body["sample_size"] == 25
|
||||||
|
assert body["radius_m"] == 800
|
||||||
|
assert len(body["nearby_poi"]) == 2
|
||||||
|
poi_types = {f["poi_type"] for f in body["nearby_poi"]}
|
||||||
|
assert poi_types == {"school", "metro_stop"}
|
||||||
|
# New contract must NOT resurrect the misleading price-multiplier fields.
|
||||||
|
assert "coef" not in body
|
||||||
|
assert "base_price_rub" not in body
|
||||||
|
assert "result_price_rub" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_index_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None:
|
||||||
|
"""Explicit radius_m outside [500, 3000] is clamped before hitting the DB query."""
|
||||||
|
db = _db_with(
|
||||||
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
||||||
|
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
|
||||||
|
_scalar_result(0), # osm_poi_ekb_local count
|
||||||
|
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
|
||||||
|
_mapping_one_result({"median_ppm2": 160_000.0, "n": 50}), # explicit radius query
|
||||||
|
)
|
||||||
|
client = _client_with(trade_in_app, db, role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/trade-in/location-index",
|
||||||
|
params={"estimate_id": _ESTIMATE_ID, "radius_m": 50},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["radius_m"] == 500
|
||||||
|
# 5th execute() call (index 4) is the explicit-radius local-median query.
|
||||||
|
nearest_call = db.execute.call_args_list[4]
|
||||||
|
params = (
|
||||||
|
nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params")
|
||||||
|
)
|
||||||
|
assert params["radius_m"] == 500
|
||||||
|
|
@ -60,7 +60,7 @@ import {
|
||||||
useEstimateMutation,
|
useEstimateMutation,
|
||||||
useEstimatePlacementHistory,
|
useEstimatePlacementHistory,
|
||||||
useEstimateSellTimeSensitivity,
|
useEstimateSellTimeSensitivity,
|
||||||
useLocationCoef,
|
useLocationIndex,
|
||||||
useSalesVsListings,
|
useSalesVsListings,
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
|
|
@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = {
|
||||||
houseType: "—",
|
houseType: "—",
|
||||||
repair: "—",
|
repair: "—",
|
||||||
balcony: false,
|
balcony: false,
|
||||||
locationCoef: "—",
|
locationIndexLabel: "—",
|
||||||
|
locationIndexOk: false,
|
||||||
lat: null,
|
lat: null,
|
||||||
lon: null,
|
lon: null,
|
||||||
};
|
};
|
||||||
|
|
@ -471,7 +472,7 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
// L3 — once the restore-by-id fetch has confirmed the estimate does not
|
// L3 — once the restore-by-id fetch has confirmed the estimate does not
|
||||||
// exist (404), `currentEstimateId` below drops to null so the sibling
|
// exist (404), `currentEstimateId` below drops to null so the sibling
|
||||||
// dashboard hooks (analytics/location-coef/placement-history/sell-time,
|
// dashboard hooks (analytics/location-index/placement-history/sell-time,
|
||||||
// all `enabled: estimate_id !== null`) don't each fire their own doomed
|
// all `enabled: estimate_id !== null`) don't each fire their own doomed
|
||||||
// request against the same dead id. Hoisted above the sub-hooks (was
|
// request against the same dead id. Hoisted above the sub-hooks (was
|
||||||
// computed further down, after they'd already fired on the stale id).
|
// computed further down, after they'd already fired on the stale id).
|
||||||
|
|
@ -532,10 +533,10 @@ export default function TradeInV2Page() {
|
||||||
estimate?.rooms ?? null,
|
estimate?.rooms ?? null,
|
||||||
);
|
);
|
||||||
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
||||||
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve
|
// LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
|
||||||
// contract: a pending/errored/unavailable response degrades to the mapper's
|
// pending/errored/degraded response resolves to the mapper's own honest,
|
||||||
// honest "—" (mapObject/mapLocation), never a fabricated coefficient.
|
// distinct reason (mapObject/mapLocation) — never a fabricated percent.
|
||||||
const locationCoef = useLocationCoef(currentEstimateId);
|
const locationIndex = useLocationIndex(currentEstimateId);
|
||||||
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
|
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
|
||||||
// contract: each resolves independently; a pending/errored one degrades its
|
// contract: each resolves independently; a pending/errored one degrades its
|
||||||
// overlay section to an honest empty via the mapper (null input).
|
// overlay section to an honest empty via the mapper (null input).
|
||||||
|
|
@ -549,7 +550,7 @@ export default function TradeInV2Page() {
|
||||||
|
|
||||||
const streetDealsData = streetDeals.data ?? null;
|
const streetDealsData = streetDeals.data ?? null;
|
||||||
const analyticsData = analytics.data ?? null;
|
const analyticsData = analytics.data ?? null;
|
||||||
const locationCoefData = locationCoef.data ?? null;
|
const locationIndexData = locationIndex.data ?? null;
|
||||||
const placementHistoryData = placementHistory.data ?? null;
|
const placementHistoryData = placementHistory.data ?? null;
|
||||||
const salesVsListingsData = salesVsListings.data ?? null;
|
const salesVsListingsData = salesVsListings.data ?? null;
|
||||||
const sellTimeData = sellTime.data ?? null;
|
const sellTimeData = sellTime.data ?? null;
|
||||||
|
|
@ -575,12 +576,12 @@ export default function TradeInV2Page() {
|
||||||
[estimate],
|
[estimate],
|
||||||
);
|
);
|
||||||
const objectInfo = useMemo(
|
const objectInfo = useMemo(
|
||||||
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT),
|
() => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
|
||||||
[estimate, locationCoefData],
|
[estimate, locationIndexData],
|
||||||
);
|
);
|
||||||
const locationData = useMemo(
|
const locationData = useMemo(
|
||||||
() => mapLocation(locationCoefData),
|
() => mapLocation(locationIndexData),
|
||||||
[locationCoefData],
|
[locationIndexData],
|
||||||
);
|
);
|
||||||
const resultPanelData = useMemo(
|
const resultPanelData = useMemo(
|
||||||
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ interface HeroBarProps {
|
||||||
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
||||||
// desktop one — meta/buttons stack, the locator mini-map (and the
|
// desktop one — meta/buttons stack, the locator mini-map (and the
|
||||||
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
||||||
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index
|
||||||
// drawer, so no functionality is lost, only the redundant map-card copy.
|
// drawer, so no functionality is lost, only the redundant map-card copy.
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +441,7 @@ export default function HeroBar({
|
||||||
the box is a fixed 560×152 with several absolutely-positioned
|
the box is a fixed 560×152 with several absolutely-positioned
|
||||||
children (address card) pinned to that size, so it cannot reflow to
|
children (address card) pinned to that size, so it cannot reflow to
|
||||||
a phone width. «КАК РАССЧИТАНО» above still opens the same
|
a phone width. «КАК РАССЧИТАНО» above still opens the same
|
||||||
location-coef drawer, so no functionality is lost.
|
location-index drawer, so no functionality is lost.
|
||||||
User-reported bug: this used to be a single static building.png
|
User-reported bug: this used to be a single static building.png
|
||||||
photo shown for EVERY estimate regardless of the real address (a
|
photo shown for EVERY estimate regardless of the real address (a
|
||||||
user could be looking at someone else's building) — replaced with a
|
user could be looking at someone else's building) — replaced with a
|
||||||
|
|
@ -547,7 +547,14 @@ export default function HeroBar({
|
||||||
onOpenInfo();
|
onOpenInfo();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Пояснение к расчёту коэффициента локации"
|
// Honest framing (post location-coef rewrite, see
|
||||||
|
// backend/app/services/location_index.py): this is a comparison
|
||||||
|
// vs. the city median, not a price multiplier, and it never
|
||||||
|
// affects the quoted estimate. title= is a plain hover tooltip
|
||||||
|
// (zero layout cost) carrying that caveat since the compact pill
|
||||||
|
// has no room to spell it out inline.
|
||||||
|
aria-label="Локация относительно города — справочно, не влияет на оценку"
|
||||||
|
title="Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет."
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -566,10 +573,21 @@ export default function HeroBar({
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
КОЭФ. ЛОКАЦИИ
|
ЛОКАЦИЯ
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
{data.object.locationCoef === "—" ? (
|
{data.object.locationIndexOk ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: tokens.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.object.locationIndexLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "9px",
|
fontSize: "9px",
|
||||||
|
|
@ -583,22 +601,13 @@ export default function HeroBar({
|
||||||
padding: "1px 8px",
|
padding: "1px 8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* #2317: coef is a live feature now (GET /location-coef) — a
|
{/* Distinct honest reasons instead of one blank dash — see
|
||||||
dash here means unavailable/loading for THIS estimate
|
mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ"
|
||||||
(unavailable geo_source, no lat/lon, or query pending),
|
(out_of_coverage — index only covers Yekaterinburg),
|
||||||
never "not built yet", so «скоро» would be stale/false. */}
|
"мало данных" (insufficient_data — too few comparable
|
||||||
нет данных
|
listings), "нет данных" (not fetched yet for this
|
||||||
</span>
|
estimate). Full explanation lives in the drawer below. */}
|
||||||
) : (
|
{data.object.locationIndexLabel}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: tokens.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.object.locationCoef}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,128 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||||
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to
|
// design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
|
||||||
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
// a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||||
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
||||||
// footer — none of which the backend produced at the time (location-coef was
|
// footer. That location-coef metric was replaced outright (see
|
||||||
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef
|
// backend/app/services/location_index.py for the full audit): ±5% range,
|
||||||
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI
|
// uncorrelated with real prices, never actually fed the estimate. This drawer
|
||||||
// factor list when available, and an HONEST "недоступно" state (never a fake
|
// now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
|
||||||
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale
|
// % deviation of the local median ₽/м² from the citywide median — framed as a
|
||||||
// for this environment, or the estimate has no lat/lon). Keeps the same drawer
|
// comparison metric, explicitly NOT a price adjustment — plus the real
|
||||||
// shell / slide animation / close button. Open/close is driven entirely by
|
// nearest-POI list. The three degraded states (loading / out_of_coverage /
|
||||||
// props; while open it is a real modal dialog (role=dialog/aria-modal,
|
// insufficient_data) each get their own honest explanation instead of one
|
||||||
// focus-trap, Esc) with semantics mirrored from SectionOverlay.
|
// blank "недоступно". Keeps the same drawer shell / slide animation / close
|
||||||
|
// button. Open/close is driven entirely by props; while open it is a real
|
||||||
|
// modal dialog (role=dialog/aria-modal, focus-trap, Esc) with semantics
|
||||||
|
// mirrored from SectionOverlay.
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
|
import { pluralRu } from "./mappers";
|
||||||
import type { LocationData } from "./mappers";
|
import type { LocationData } from "./mappers";
|
||||||
|
|
||||||
// Default presentation data (unwired usage / no coef fetched yet): the honest
|
// Default presentation data (unwired usage / not fetched yet): the honest
|
||||||
// unavailable state, never a fabricated coefficient.
|
// loading state, never a fabricated coefficient.
|
||||||
const LOCATION_FIXTURE: LocationData = {
|
const LOCATION_FIXTURE: LocationData = {
|
||||||
available: false,
|
status: "loading",
|
||||||
coefDelta: "—",
|
indexLabel: "—",
|
||||||
baseLabel: "—",
|
localMedianLabel: "—",
|
||||||
resultLabel: "—",
|
cityMedianLabel: "—",
|
||||||
|
sampleSize: 0,
|
||||||
|
radiusLabel: "—",
|
||||||
|
poiAvailable: false,
|
||||||
factors: [],
|
factors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// data.indexLabel is already the signed, rounded fmtPct string produced by
|
||||||
|
// mapLocation ("+12%" / "−8%" / "0%" / "—") — reusing it here (rather than a
|
||||||
|
// second raw-number field) keeps the sign/rounding logic in one place
|
||||||
|
// (./mappers.ts). Turns it into a plain-language comparison sentence instead
|
||||||
|
// of a bare percent, so it reads as "vs. the city", never as a price change.
|
||||||
|
function locationDirectionSentence(indexLabel: string): string {
|
||||||
|
if (indexLabel.startsWith("−")) {
|
||||||
|
return `Район дешевле города на ${indexLabel.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (indexLabel.startsWith("+")) {
|
||||||
|
return `Район дороже города на ${indexLabel.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (indexLabel === "0%") return "Район на уровне медианы по городу";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Что рядом» — qualitative POI list, independent of the numeric index
|
||||||
|
// (poi_status degrades separately from status, see mapLocation/./mappers.ts).
|
||||||
|
function PoiSection({ data }: { data: LocationData }) {
|
||||||
|
if (!data.poiAvailable) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11.5, color: tokens.muted3 }}>
|
||||||
|
Данные о ближайшей инфраструктуре сейчас недоступны.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "1px",
|
||||||
|
color: tokens.muted2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ЧТО РЯДОМ
|
||||||
|
</div>
|
||||||
|
{data.factors.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: "none",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.factors.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 11.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: tokens.ink2 }}>
|
||||||
|
{f.label}
|
||||||
|
{f.category !== f.label && (
|
||||||
|
<span style={{ color: tokens.muted3 }}> · {f.category}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: tokens.muted,
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.distance}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
|
||||||
|
Объектов инфраструктуры в радиусе поиска не найдено.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface LocationDrawerProps {
|
interface LocationDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -313,142 +408,95 @@ export function LocationDrawer({
|
||||||
>
|
>
|
||||||
ЛОКАЦИЯ
|
ЛОКАЦИЯ
|
||||||
</div>
|
</div>
|
||||||
{data.available ? (
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
fontSize: 12.5,
|
||||||
fontSize: 12.5,
|
lineHeight: 1.7,
|
||||||
lineHeight: 1.7,
|
color: tokens.body2,
|
||||||
color: tokens.body2,
|
background: tokens.infoSoftBg,
|
||||||
background: tokens.infoSoftBg,
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
borderRadius: 7,
|
||||||
borderRadius: 7,
|
padding: "12px 14px",
|
||||||
padding: "12px 14px",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{data.status === "ok" && (
|
||||||
<div
|
<>
|
||||||
style={{
|
<div
|
||||||
display: "flex",
|
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>Без поправки на локацию</span>
|
|
||||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}>
|
|
||||||
{data.baseLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginTop: 4,
|
|
||||||
marginBottom: data.factors.length > 0 ? 10 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>С поправкой на локацию ({data.coefDelta})</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: tokens.accent,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{data.resultLabel}
|
{locationDirectionSentence(data.indexLabel)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Fix #7c (audit): the estimator never reads this coefficient — it's
|
|
||||||
illustrative/reference only. Without this line the ₽-vs-₽ layout
|
|
||||||
above reads as if it changes the quoted price. */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 10.5,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: tokens.muted3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Справочно: поправка на локацию не влияет на итоговую оценку.
|
|
||||||
</div>
|
|
||||||
{data.factors.length > 0 ? (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
listStyle: "none",
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.factors.map((f, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 10,
|
|
||||||
fontSize: 11.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: tokens.ink2 }}>
|
|
||||||
{f.label}
|
|
||||||
{f.category !== f.label && (
|
|
||||||
<span style={{ color: tokens.muted3 }}>
|
|
||||||
{" "}
|
|
||||||
· {f.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: "0 0 auto",
|
|
||||||
color: tokens.muted,
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{f.distance}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
|
|
||||||
Объектов инфраструктуры в радиусе поиска не найдено.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: "flex",
|
||||||
marginTop: 12,
|
alignItems: "baseline",
|
||||||
fontSize: 10.5,
|
justifyContent: "space-between",
|
||||||
lineHeight: 1.5,
|
marginTop: 10,
|
||||||
color: tokens.muted3,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span>Медиана ₽/м² рядом (радиус {data.radiusLabel})</span>
|
||||||
Ориентировочная поправка по близости инфраструктуры
|
<span
|
||||||
(OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована
|
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||||
на реальных ценовых сделках.
|
>
|
||||||
</div>
|
{data.localMedianLabel}
|
||||||
</div>
|
</span>
|
||||||
) : (
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12.5,
|
display: "flex",
|
||||||
lineHeight: 1.7,
|
alignItems: "baseline",
|
||||||
color: tokens.body2,
|
justifyContent: "space-between",
|
||||||
background: tokens.infoSoftBg,
|
marginTop: 4,
|
||||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
}}
|
||||||
borderRadius: 7,
|
>
|
||||||
padding: "12px 14px",
|
<span>Медиана ₽/м² по Екатеринбургу</span>
|
||||||
}}
|
<span
|
||||||
>
|
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||||
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "}
|
>
|
||||||
<b style={{ color: tokens.ink2 }}>недоступны</b> — коэффициент
|
{data.cityMedianLabel}
|
||||||
локации не корректирует текущую оценку.
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Honest, not illustrative: this metric никогда не идёт в цену
|
||||||
|
(аналоги уже берутся из этого же района — повторный учёт
|
||||||
|
локации был бы задвоением, см. app/services/location_index.py). */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 10.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: tokens.muted3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Посчитано по {data.sampleSize}{" "}
|
||||||
|
{pluralRu(data.sampleSize, [
|
||||||
|
"объявлению",
|
||||||
|
"объявлениям",
|
||||||
|
"объявлениям",
|
||||||
|
])}{" "}
|
||||||
|
в радиусе {data.radiusLabel}. Справочно — на итоговую оценку не
|
||||||
|
влияет: аналоги для расчёта уже берутся из этого района.
|
||||||
|
</div>
|
||||||
|
<PoiSection data={data} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.status === "out_of_coverage" && (
|
||||||
|
<>
|
||||||
|
Локационный индекс считаем только по{" "}
|
||||||
|
<b style={{ color: tokens.ink2 }}>Екатеринбургу</b> — этот адрес
|
||||||
|
вне зоны покрытия, сравнение с городом недоступно.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.status === "insufficient_data" && (
|
||||||
|
<>
|
||||||
|
Рядом нашлось только{" "}
|
||||||
|
<b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
|
||||||
|
сопоставимых объявлений (радиус {data.radiusLabel}) — этого
|
||||||
|
недостаточно для надёжного сравнения с городом.
|
||||||
|
<PoiSection data={data} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.status === "loading" && "Считаем локационный индекс…"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import type {
|
||||||
DealRow,
|
DealRow,
|
||||||
DropdownOptions,
|
DropdownOptions,
|
||||||
History,
|
History,
|
||||||
LocationFactors,
|
|
||||||
MarketAds,
|
MarketAds,
|
||||||
MarketDeals,
|
MarketDeals,
|
||||||
ObjectInfo,
|
ObjectInfo,
|
||||||
|
|
@ -44,7 +43,8 @@ export const object: ObjectInfo = {
|
||||||
houseType: "Панельный",
|
houseType: "Панельный",
|
||||||
repair: "Хороший",
|
repair: "Хороший",
|
||||||
balcony: true,
|
balcony: true,
|
||||||
locationCoef: "0.87",
|
locationIndexLabel: "+8%",
|
||||||
|
locationIndexOk: true,
|
||||||
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
||||||
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
||||||
lat: 56.8384,
|
lat: 56.8384,
|
||||||
|
|
@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
|
||||||
|
// + base/coef/result formula + positives/negatives) lived here — removed
|
||||||
export const locationFactors: LocationFactors = {
|
// together with the location-index rewrite. LocationDrawer now reads
|
||||||
coef: "0.87",
|
// `LocationData` produced by `mapLocation` (./mappers.ts) from the real
|
||||||
intro:
|
// GET /trade-in/location-index response; its own default/unwired fixture is
|
||||||
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
// LOCATION_FIXTURE in ./LocationDrawer.tsx.
|
||||||
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
|
|
||||||
positives: [
|
|
||||||
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
|
|
||||||
{ label: "Транспортные узлы и остановки рядом", delta: "+0.05" },
|
|
||||||
{ label: "Набережная и парк в 10 минутах", delta: "+0.04" },
|
|
||||||
{ label: "Развитая торговая инфраструктура", delta: "+0.03" },
|
|
||||||
],
|
|
||||||
negatives: [
|
|
||||||
{ label: "Оживлённая магистраль, шумовая нагрузка", delta: "−0.07" },
|
|
||||||
{ label: "Дефицит парковочных мест", delta: "−0.05" },
|
|
||||||
{ label: "Возраст жилого фонда района (1985)", delta: "−0.04" },
|
|
||||||
{ label: "Износ инженерных сетей квартала", delta: "−0.02" },
|
|
||||||
],
|
|
||||||
footer:
|
|
||||||
"Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- NAV / CHROME ---------------------------------------------------------
|
// ---- NAV / CHROME ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@
|
||||||
// source groupBy) — out of scope for #2040/#2041, left as-is.
|
// source groupBy) — out of scope for #2040/#2041, left as-is.
|
||||||
// BE-2 target_address is a single string; street/city are split heuristically
|
// BE-2 target_address is a single string; street/city are split heuristically
|
||||||
// (parseAddress). Backend should return structured address components.
|
// (parseAddress). Backend should return structured address components.
|
||||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
// BE-3 location index (replacing the broken location-coef, see
|
||||||
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
// backend/app/services/location_index.py) is wired here
|
||||||
|
// (mapObject/mapLocation consume GET /trade-in/location-index).
|
||||||
//
|
//
|
||||||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||||||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||||
|
|
@ -34,7 +35,7 @@ import type {
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseType,
|
HouseType,
|
||||||
LocationCoefResponse,
|
LocationIndexResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
RepairState,
|
RepairState,
|
||||||
SalesVsListingsResponse,
|
SalesVsListingsResponse,
|
||||||
|
|
@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Location-coefficient delta label shared by mapObject (HeroBar tile) and
|
* Compact HeroBar badge for the location index — {label, ok} shared by
|
||||||
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05]
|
* mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
|
||||||
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price
|
* reuse. location_index_pct is a real % deviation of the local median ₽/м²
|
||||||
* deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the
|
* from the citywide median (see backend/app/services/location_index.py) — a
|
||||||
* other honest deltas on this page), never a raw multiplier that would read as
|
* comparison metric, NOT a price multiplier, and it does NOT feed the
|
||||||
* more precise than it is. "—" while absent/loading AND when
|
* estimate. `ok: true` only for status="ok" (a trustworthy percent); every
|
||||||
* geo_source="unavailable" (legitimate graceful fallback, not an error).
|
* other case gets a short, honest, DISTINCT reason instead of a blank dash —
|
||||||
|
* the drawer (mapLocation below) expands on each:
|
||||||
|
* - li == null → "нет данных" (not fetched yet / this estimate
|
||||||
|
* has no location index request enabled)
|
||||||
|
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
|
||||||
|
* - "insufficient_data" → "мало данных" (too few comparable listings)
|
||||||
*/
|
*/
|
||||||
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string {
|
function locationIndexBadge(
|
||||||
if (lc == null || lc.geo_source === "unavailable") return "—";
|
li: LocationIndexResponse | null | undefined,
|
||||||
return fmtPct((lc.coef - 1) * 100);
|
): { label: string; ok: boolean } {
|
||||||
|
if (li == null) return { label: "нет данных", ok: false };
|
||||||
|
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
|
||||||
|
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
|
||||||
|
return { label: "мало данных", ok: false }; // status === "insufficient_data"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
|
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
|
||||||
* `coef` is the GET /trade-in/location-coef response (#2317) — optional/null
|
* `locationIndex` is the GET /trade-in/location-index response — optional/null
|
||||||
* while it is still loading or unavailable for this address.
|
* while it is still loading or not requested for this address.
|
||||||
*/
|
*/
|
||||||
export function mapObject(
|
export function mapObject(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
coef?: LocationCoefResponse | null,
|
locationIndex?: LocationIndexResponse | null,
|
||||||
): ObjectInfo {
|
): ObjectInfo {
|
||||||
const { address, city } = parseAddress(e.target_address);
|
const { address, city } = parseAddress(e.target_address);
|
||||||
|
const badge = locationIndexBadge(locationIndex);
|
||||||
return {
|
return {
|
||||||
address,
|
address,
|
||||||
city,
|
city,
|
||||||
|
|
@ -817,7 +828,8 @@ export function mapObject(
|
||||||
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
||||||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||||
balcony: e.has_balcony ?? false,
|
balcony: e.has_balcony ?? false,
|
||||||
locationCoef: coefDeltaLabel(coef),
|
locationIndexLabel: badge.label,
|
||||||
|
locationIndexOk: badge.ok,
|
||||||
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
|
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
|
||||||
// null while the estimate has no geocode yet.
|
// null while the estimate has no geocode yet.
|
||||||
lat: e.target_lat,
|
lat: e.target_lat,
|
||||||
|
|
@ -825,10 +837,11 @@ export function mapObject(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ─────────────────────────────
|
// ── Location index (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────────
|
||||||
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
|
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
|
||||||
// backend/app/services/location_coef.py (top7 straight-line POI score). An
|
// backend/app/services/location_index.py (top-N straight-line POI ranking —
|
||||||
// unrecognised category (raw OSM tag outside that dict) falls back to a
|
// a qualitative "what's nearby" list only, no longer feeding any score/coef).
|
||||||
|
// An unrecognised category (raw OSM tag outside that dict) falls back to a
|
||||||
// generic label rather than surfacing a raw enum-ish string to the user.
|
// generic label rather than surfacing a raw enum-ish string to the user.
|
||||||
const POI_CATEGORY_RU: Record<string, string> = {
|
const POI_CATEGORY_RU: Record<string, string> = {
|
||||||
metro_stop: "Метро",
|
metro_stop: "Метро",
|
||||||
|
|
@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string {
|
||||||
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
|
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One POI row in the LocationDrawer factor list. */
|
/** One POI row in the LocationDrawer «что рядом» list. */
|
||||||
export interface LocationFactorRow {
|
export interface LocationFactorRow {
|
||||||
label: string; // POI name if known, else its RU category
|
label: string; // POI name if known, else its RU category
|
||||||
category: string; // RU category label (always present, for the badge)
|
category: string; // RU category label (always present, for the badge)
|
||||||
distance: string; // "150 м" / "1.2 км"
|
distance: string; // "150 м" / "1.2 км"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own
|
||||||
|
* three-way honest-degradation contract ("loading" is an FE-only 4th state
|
||||||
|
* for li == null, e.g. query still pending) so the drawer can explain EACH
|
||||||
|
* case differently instead of collapsing them into one dash:
|
||||||
|
* - "ok" index/medians reliable, show the real numbers.
|
||||||
|
* - "out_of_coverage" address outside Yekaterinburg — the index simply
|
||||||
|
* doesn't cover it (not an error, not "no data").
|
||||||
|
* - "insufficient_data" too few comparable listings even at the widest
|
||||||
|
* radius — sampleSize/radiusLabel still say what was actually found.
|
||||||
|
* - "loading" not fetched yet.
|
||||||
|
* indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the
|
||||||
|
* corresponding backend field is null (never a fabricated number).
|
||||||
|
*/
|
||||||
export interface LocationData {
|
export interface LocationData {
|
||||||
// true when the backend actually computed a coefficient for this address
|
status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
|
||||||
// (geo_source="osm_poi_ekb"), even if no POI were found within radius
|
indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
|
||||||
// (factors=[] is then a legitimate empty result, not "no data").
|
localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
|
||||||
available: boolean;
|
cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
|
||||||
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable
|
sampleSize: number; // comparable active listings actually found (0 if n/a)
|
||||||
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽"
|
radiusLabel: string; // fmtDist(radius_m), "—" while loading
|
||||||
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽"
|
poiAvailable: boolean; // poi_status === "ok" (independent of status above)
|
||||||
factors: LocationFactorRow[];
|
factors: LocationFactorRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef
|
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
|
||||||
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest
|
* null/undefined (not fetched yet) degrades to status="loading" — never a
|
||||||
* unavailable state — never a fabricated coefficient, price or factor list
|
* fabricated index, price or factor list (mirrors the backend's own
|
||||||
* (mirrors the backend's own graceful-fallback contract).
|
* graceful-fallback contract).
|
||||||
*/
|
*/
|
||||||
export function mapLocation(
|
export function mapLocation(
|
||||||
lc: LocationCoefResponse | null | undefined,
|
li: LocationIndexResponse | null | undefined,
|
||||||
): LocationData {
|
): LocationData {
|
||||||
if (lc == null || lc.geo_source === "unavailable") {
|
if (li == null) {
|
||||||
return {
|
return {
|
||||||
available: false,
|
status: "loading",
|
||||||
coefDelta: "—",
|
indexLabel: "—",
|
||||||
baseLabel: "—",
|
localMedianLabel: "—",
|
||||||
resultLabel: "—",
|
cityMedianLabel: "—",
|
||||||
|
sampleSize: 0,
|
||||||
|
radiusLabel: "—",
|
||||||
|
poiAvailable: false,
|
||||||
factors: [],
|
factors: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
available: true,
|
status: li.status,
|
||||||
coefDelta: coefDeltaLabel(lc),
|
indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
|
||||||
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`,
|
localMedianLabel: fmtPpm(li.local_median_price_per_m2),
|
||||||
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`,
|
cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
|
||||||
factors: lc.factors.map((f) => ({
|
sampleSize: li.sample_size,
|
||||||
label: f.name?.trim() || poiCategoryLabel(f.poi_type),
|
radiusLabel: fmtDist(li.radius_m),
|
||||||
category: poiCategoryLabel(f.poi_type),
|
poiAvailable: li.poi_status === "ok",
|
||||||
distance: fmtDist(f.distance_m),
|
factors: li.nearby_poi.map((p) => ({
|
||||||
|
label: p.name?.trim() || poiCategoryLabel(p.poi_type),
|
||||||
|
category: poiCategoryLabel(p.poi_type),
|
||||||
|
distance: fmtDist(p.distance_m),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,15 @@ export interface ObjectInfo {
|
||||||
houseType: string;
|
houseType: string;
|
||||||
repair: string;
|
repair: string;
|
||||||
balcony: boolean;
|
balcony: boolean;
|
||||||
locationCoef: string;
|
// Compact HeroBar badge for the location index (see mapObject / mappers.ts
|
||||||
|
// locationIndexBadge): "+12%"/"−8%" when the backend has a reliable value
|
||||||
|
// (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" /
|
||||||
|
// "нет данных" while loading) — never a fabricated percent.
|
||||||
|
locationIndexLabel: string;
|
||||||
|
// true only when locationIndexLabel is a real percent (status="ok") — tells
|
||||||
|
// HeroBar whether to render it as the accent numeric value or as the muted
|
||||||
|
// unavailable-reason pill.
|
||||||
|
locationIndexOk: boolean;
|
||||||
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
|
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
|
||||||
// when the estimate has no geocode yet — the map then renders an honest
|
// when the estimate has no geocode yet — the map then renders an honest
|
||||||
// "нет координат" placeholder instead of an empty/broken box.
|
// "нет координат" placeholder instead of an empty/broken box.
|
||||||
|
|
@ -287,21 +295,11 @@ export interface DropdownOptions {
|
||||||
crm: string[];
|
crm: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
// Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/
|
||||||
|
// result formula + positives/negatives) lived here — removed together with
|
||||||
export interface LocationFactor {
|
// the location-index rewrite: LocationDrawer now consumes `LocationData`
|
||||||
label: string;
|
// (see ./mappers.ts), which reflects the real backend contract, not the old
|
||||||
delta: string;
|
// fabricated formula.
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocationFactors {
|
|
||||||
coef: string;
|
|
||||||
intro: string;
|
|
||||||
formula: { base: string; coef: string; result: string };
|
|
||||||
positives: LocationFactor[];
|
|
||||||
negatives: LocationFactor[];
|
|
||||||
footer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- USER / CHROME --------------------------------------------------------
|
// ---- USER / CHROME --------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import type {
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
LocationCoefResponse,
|
LocationIndexResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
SalesVsListingsResponse,
|
SalesVsListingsResponse,
|
||||||
SellTimeSensitivityResponse,
|
SellTimeSensitivityResponse,
|
||||||
|
|
@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/trade-in/location-coef?estimate_id=&radius_m=
|
* GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
|
||||||
* POI-based location coefficient for the estimate's target address (#2045 BE-3
|
* Location index for the estimate's target address — replaces the broken
|
||||||
* backend, #2317 FE wiring — LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is
|
* location-coef (backend rewrite, see app/services/location_index.py):
|
||||||
* an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas.
|
* % deviation of the local median ₽/м² (comparable active listings near the
|
||||||
* geo_source="unavailable" is a legitimate graceful-fallback response (local POI
|
* address) from the citywide median ₽/м², NOT a price multiplier and NOT fed
|
||||||
* mirror empty/stale on this environment, or the estimate has no lat/lon) —
|
* into the estimate. status="out_of_coverage"/"insufficient_data" are honest
|
||||||
* ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient.
|
* graceful-fallback responses (address outside Yekaterinburg / too few
|
||||||
|
* comparables) — ./v2/mappers.ts renders each distinctly, never a fabricated
|
||||||
|
* number.
|
||||||
*/
|
*/
|
||||||
export function useLocationCoef(estimate_id: string | null, radius_m?: number) {
|
export function useLocationIndex(estimate_id: string | null, radius_m?: number) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (estimate_id) params.set("estimate_id", estimate_id);
|
if (estimate_id) params.set("estimate_id", estimate_id);
|
||||||
if (radius_m != null) params.set("radius_m", String(radius_m));
|
if (radius_m != null) params.set("radius_m", String(radius_m));
|
||||||
return useQuery<LocationCoefResponse>({
|
return useQuery<LocationIndexResponse>({
|
||||||
queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null],
|
queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`),
|
apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
|
||||||
enabled: estimate_id !== null && estimate_id.length > 0,
|
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -470,27 +470,41 @@ export interface TradeInLeadResponse {
|
||||||
status: string; // 'received'
|
status: string; // 'received'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ──
|
// ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ──
|
||||||
// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ.
|
// Replaces the broken location-coef (#2045 audit — see
|
||||||
// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py
|
// backend/app/services/location_index.py for the full history: the old
|
||||||
// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05].
|
// `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real
|
||||||
// result_price_rub = round(base_price_rub * coef).
|
// prices, and never actually fed the estimate). location_index_pct is the %
|
||||||
|
// deviation of the local median ₽/м² (comparable active listings near the
|
||||||
|
// address) from the citywide median ₽/м² — a real, uncalibrated-range
|
||||||
|
// comparison metric, NOT a price multiplier. It does NOT feed the estimate
|
||||||
|
// (analogs already carry location in the base price; folding this in again
|
||||||
|
// would double-count the same effect).
|
||||||
//
|
//
|
||||||
// geo_source="unavailable" is a legitimate graceful-fallback response (the local
|
// status:
|
||||||
// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate
|
// "ok" — location_index_pct/local_median_price_per_m2 reliable.
|
||||||
// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated.
|
// "out_of_coverage" — address outside the product's geo coverage
|
||||||
// "osm_poi_ekb" is the normal/real-data source.
|
// (Yekaterinburg only). All numeric fields null — an honest dash, not 0%.
|
||||||
export interface LocationCoefFactor {
|
// "insufficient_data" — even at the widest search radius there are too few
|
||||||
|
// comparable active listings. Numeric fields null; sample_size/radius_m
|
||||||
|
// still report what was actually found.
|
||||||
|
//
|
||||||
|
// poi_status is independent of status above ("что рядом" and the numeric
|
||||||
|
// index degrade separately): "ok" | "unavailable" (local OSM POI mirror
|
||||||
|
// empty/not yet refreshed on this environment — never fabricated points).
|
||||||
|
export interface NearbyPoi {
|
||||||
poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten"
|
poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten"
|
||||||
name: string | null; // POI name if known
|
name: string | null; // POI name if known
|
||||||
distance_m: number;
|
distance_m: number;
|
||||||
weight: number; // internal score contribution — NOT a per-POI price delta
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocationCoefResponse {
|
export interface LocationIndexResponse {
|
||||||
coef: number;
|
status: "ok" | "out_of_coverage" | "insufficient_data";
|
||||||
factors: LocationCoefFactor[];
|
location_index_pct: number | null;
|
||||||
geo_source: string; // "osm_poi_ekb" | "unavailable"
|
local_median_price_per_m2: number | null;
|
||||||
base_price_rub: number;
|
city_median_price_per_m2: number | null;
|
||||||
result_price_rub: number;
|
sample_size: number;
|
||||||
|
radius_m: number;
|
||||||
|
nearby_poi: NearbyPoi[];
|
||||||
|
poi_status: "ok" | "unavailable";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue