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"
|
||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||
|
||||
# Маппинг OSM-тег → нормализованная category
|
||||
OSM_CATEGORIES: dict[tuple[str, str], str] = {
|
||||
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||
# 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", "school"): "school",
|
||||
("amenity", "college"): "school",
|
||||
("amenity", "university"): "school",
|
||||
("amenity", "kindergarten"): "kindergarten",
|
||||
("amenity", "pharmacy"): "pharmacy",
|
||||
("amenity", "hospital"): "hospital",
|
||||
("amenity", "clinic"): "hospital",
|
||||
(("amenity", "school"),): "school",
|
||||
(("amenity", "college"),): "school",
|
||||
(("amenity", "university"),): "school",
|
||||
(("amenity", "kindergarten"),): "kindergarten",
|
||||
(("amenity", "pharmacy"),): "pharmacy",
|
||||
(("amenity", "hospital"),): "hospital",
|
||||
(("amenity", "clinic"),): "hospital",
|
||||
# shop tags — supermarket расширен
|
||||
("shop", "mall"): "shop_mall",
|
||||
("shop", "supermarket"): "shop_supermarket",
|
||||
("shop", "hypermarket"): "shop_supermarket",
|
||||
("shop", "convenience"): "shop_small",
|
||||
("shop", "bakery"): "shop_small",
|
||||
(("shop", "mall"),): "shop_mall",
|
||||
(("shop", "supermarket"),): "shop_supermarket",
|
||||
(("shop", "hypermarket"),): "shop_supermarket",
|
||||
(("shop", "convenience"),): "shop_small",
|
||||
(("shop", "bakery"),): "shop_small",
|
||||
# leisure
|
||||
("leisure", "park"): "park",
|
||||
(("leisure", "park"),): "park",
|
||||
# transit
|
||||
("railway", "tram_stop"): "tram_stop",
|
||||
("highway", "bus_stop"): "bus_stop",
|
||||
# метро (одна линия в ЕКБ, но добавляем для полноты)
|
||||
("station", "subway"): "metro_stop",
|
||||
(("railway", "tram_stop"),): "tram_stop",
|
||||
(("highway", "bus_stop"),): "bus_stop",
|
||||
# Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил
|
||||
# ТОЛЬКО 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:
|
||||
"""Запрос для одной пары tag → нормированной категории.
|
||||
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||
|
||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||
14 быстрых запросов вместо одного 60+ секундного.
|
||||
быстрые запросы вместо одного 60+ секундного.
|
||||
"""
|
||||
south, west, north, east = EKB_BBOX
|
||||
bbox = f"({south},{west},{north},{east})"
|
||||
return (
|
||||
f"[out:json][timeout:30];"
|
||||
f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);'
|
||||
f"out center meta;"
|
||||
)
|
||||
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||
|
||||
|
||||
def _classify(tags: dict[str, str]) -> str | None:
|
||||
"""Определить category из OSM-тегов. None если не соответствует ни одной."""
|
||||
for (k, v), cat in OSM_CATEGORIES.items():
|
||||
if tags.get(k) == v:
|
||||
for tag_filters, cat in OSM_CATEGORIES.items():
|
||||
if all(tags.get(k) == v for k, v in tag_filters):
|
||||
return cat
|
||||
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]:
|
||||
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||
|
||||
Делаем 14 отдельных запросов вместо одного гигантского — большой запрос
|
||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||
policy: max 2 concurrent, лучше 1 req/s).
|
||||
|
||||
|
|
@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]:
|
|||
}
|
||||
all_elements: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||
for (key, value), category in OSM_CATEGORIES.items():
|
||||
query = _build_overpass_query_single(key, value)
|
||||
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 (%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)
|
||||
for tag_filters, category in OSM_CATEGORIES.items():
|
||||
elements = await _fetch_category(client, tag_filters, category)
|
||||
all_elements.extend(elements)
|
||||
await asyncio.sleep(1.0)
|
||||
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
|
||||
|
||||
|
|
|
|||
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,
|
||||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
LocationCoefFactorOut,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
NearbyPoiOut,
|
||||
PhotoMeta,
|
||||
PlacementHistoryEntry,
|
||||
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)
|
||||
def get_location_coef(
|
||||
@router.get("/location-index", response_model=LocationIndexResponse)
|
||||
def get_location_index(
|
||||
estimate_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
radius_m: int | None = None,
|
||||
) -> LocationCoefResponse:
|
||||
"""Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer).
|
||||
) -> LocationIndexResponse:
|
||||
"""Location index для оценки (замена сломанного location-coef, LocationDrawer).
|
||||
|
||||
Резолвит lat/lon/median_price оценки, считает coef через
|
||||
app.services.location_coef.compute_location_coef — straight-line POI weighted score
|
||||
(портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local,
|
||||
обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round(
|
||||
base_price_rub * coef).
|
||||
Резолвит lat/lon оценки, считает индекс через
|
||||
app.services.location_index.compute_location_index: % отклонения медианы ₽/м²
|
||||
сопоставимых активных листингов в радиусе точки от медианы ₽/м² по всему Екатеринбургу
|
||||
(percentile_cont(0.5) — устойчиво к выбросам). НЕ участвует в цене — estimator.py про
|
||||
этот показатель не знает (аналоги уже несут локацию в базовой цене).
|
||||
|
||||
404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
|
||||
derived-роутов). radius_m опционален (None → DEFAULT_RADIUS_M=1200м, подобран для
|
||||
МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000].
|
||||
derived-роутов). radius_m опционален (None → адаптивная лестница радиусов
|
||||
RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
|
||||
клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).
|
||||
|
||||
Graceful fallback (НЕ 500, НЕ сфабрикованные факторы):
|
||||
- osm_poi_ekb_local пуста/не отрефрешена на этом окружении → coef=1.0, factors=[],
|
||||
geo_source="unavailable" (см. compute_location_coef).
|
||||
- у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) → тот же fallback.
|
||||
Честная деградация (НЕ 500, НЕ сфабрикованные значения) — см. LocationIndexResponse:
|
||||
- status="out_of_coverage" — у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
|
||||
(Екатеринбург).
|
||||
- status="insufficient_data" — даже на максимальном радиусе сопоставимых активных
|
||||
листингов меньше порога.
|
||||
- poi_status="unavailable" — osm_poi_ekb_local пуста/не отрефрешена (независимо от
|
||||
status выше — «что рядом» и числовой индекс деградируют раздельно).
|
||||
"""
|
||||
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
|
||||
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT lat, lon, median_price
|
||||
SELECT lat, lon
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -1609,34 +1613,38 @@ def get_location_coef(
|
|||
if row is None:
|
||||
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:
|
||||
logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id)
|
||||
return LocationCoefResponse(
|
||||
coef=1.0,
|
||||
factors=[],
|
||||
geo_source="unavailable",
|
||||
base_price_rub=base_price_rub,
|
||||
result_price_rub=base_price_rub,
|
||||
logger.info(
|
||||
"location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
|
||||
)
|
||||
return LocationIndexResponse(
|
||||
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 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))
|
||||
result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
||||
resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
|
||||
result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
|
||||
|
||||
return LocationCoefResponse(
|
||||
coef=result.coef,
|
||||
factors=[
|
||||
LocationCoefFactorOut(
|
||||
poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight
|
||||
)
|
||||
for f in result.factors
|
||||
return LocationIndexResponse(
|
||||
status=result.status,
|
||||
location_index_pct=result.location_index_pct,
|
||||
local_median_price_per_m2=result.local_median_price_per_m2,
|
||||
city_median_price_per_m2=result.city_median_price_per_m2,
|
||||
sample_size=result.sample_size,
|
||||
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,
|
||||
base_price_rub=base_price_rub,
|
||||
result_price_rub=round(base_price_rub * result.coef),
|
||||
poi_status=result.poi_status,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -598,29 +598,48 @@ class QuotaStatus(BaseModel):
|
|||
unlimited: bool # True для admin / kopylov / без заголовка
|
||||
|
||||
|
||||
class LocationCoefFactorOut(BaseModel):
|
||||
"""Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3)."""
|
||||
class NearbyPoiOut(BaseModel):
|
||||
"""Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
|
||||
|
||||
Качественная справка (школа 185 м, остановка 93 м) — НЕ участвует в location_index_pct.
|
||||
"""
|
||||
|
||||
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
|
||||
# что в osm_poi_ekb на стороне gendesign)
|
||||
name: str | None
|
||||
distance_m: float
|
||||
weight: float
|
||||
|
||||
|
||||
class LocationCoefResponse(BaseModel):
|
||||
"""Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
|
||||
class LocationIndexResponse(BaseModel):
|
||||
"""Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
|
||||
|
||||
coef — MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см.
|
||||
app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05].
|
||||
result_price_rub = round(base_price_rub * coef).
|
||||
ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
|
||||
`result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах — 67% из
|
||||
1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы ₽/м² по
|
||||
4000 активным лотам (дороже — не значит выше coef). Полностью заменён.
|
||||
|
||||
geo_source="unavailable" — osm_poi_ekb_local пуста/не отрефрешена на этом окружении
|
||||
(graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы).
|
||||
location_index_pct — % отклонения медианы ₽/м² сопоставимых активных листингов в радиусе
|
||||
точки от медианы ₽/м² по всему Екатеринбургу (см. 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
|
||||
factors: list[LocationCoefFactorOut]
|
||||
geo_source: str
|
||||
base_price_rub: int
|
||||
result_price_rub: int
|
||||
status: str
|
||||
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[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
|
||||
`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
|
||||
`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
|
||||
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
|
||||
`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
|
||||
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,
|
||||
useEstimatePlacementHistory,
|
||||
useEstimateSellTimeSensitivity,
|
||||
useLocationCoef,
|
||||
useLocationIndex,
|
||||
useSalesVsListings,
|
||||
useStreetDeals,
|
||||
} from "@/lib/trade-in-api";
|
||||
|
|
@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = {
|
|||
houseType: "—",
|
||||
repair: "—",
|
||||
balcony: false,
|
||||
locationCoef: "—",
|
||||
locationIndexLabel: "—",
|
||||
locationIndexOk: false,
|
||||
lat: null,
|
||||
lon: null,
|
||||
};
|
||||
|
|
@ -471,7 +472,7 @@ export default function TradeInV2Page() {
|
|||
|
||||
// L3 — once the restore-by-id fetch has confirmed the estimate does not
|
||||
// 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
|
||||
// request against the same dead id. Hoisted above the sub-hooks (was
|
||||
// computed further down, after they'd already fired on the stale id).
|
||||
|
|
@ -532,10 +533,10 @@ export default function TradeInV2Page() {
|
|||
estimate?.rooms ?? null,
|
||||
);
|
||||
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
||||
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve
|
||||
// contract: a pending/errored/unavailable response degrades to the mapper's
|
||||
// honest "—" (mapObject/mapLocation), never a fabricated coefficient.
|
||||
const locationCoef = useLocationCoef(currentEstimateId);
|
||||
// LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
|
||||
// pending/errored/degraded response resolves to the mapper's own honest,
|
||||
// distinct reason (mapObject/mapLocation) — never a fabricated percent.
|
||||
const locationIndex = useLocationIndex(currentEstimateId);
|
||||
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
|
||||
// contract: each resolves independently; a pending/errored one degrades its
|
||||
// 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 analyticsData = analytics.data ?? null;
|
||||
const locationCoefData = locationCoef.data ?? null;
|
||||
const locationIndexData = locationIndex.data ?? null;
|
||||
const placementHistoryData = placementHistory.data ?? null;
|
||||
const salesVsListingsData = salesVsListings.data ?? null;
|
||||
const sellTimeData = sellTime.data ?? null;
|
||||
|
|
@ -575,12 +576,12 @@ export default function TradeInV2Page() {
|
|||
[estimate],
|
||||
);
|
||||
const objectInfo = useMemo(
|
||||
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT),
|
||||
[estimate, locationCoefData],
|
||||
() => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
|
||||
[estimate, locationIndexData],
|
||||
);
|
||||
const locationData = useMemo(
|
||||
() => mapLocation(locationCoefData),
|
||||
[locationCoefData],
|
||||
() => mapLocation(locationIndexData),
|
||||
[locationIndexData],
|
||||
);
|
||||
const resultPanelData = useMemo(
|
||||
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ interface HeroBarProps {
|
|||
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
||||
// 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
|
||||
// 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.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
|
@ -441,7 +441,7 @@ export default function HeroBar({
|
|||
the box is a fixed 560×152 with several absolutely-positioned
|
||||
children (address card) pinned to that size, so it cannot reflow to
|
||||
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
|
||||
photo shown for EVERY estimate regardless of the real address (a
|
||||
user could be looking at someone else's building) — replaced with a
|
||||
|
|
@ -547,7 +547,14 @@ export default function HeroBar({
|
|||
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={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -566,10 +573,21 @@ export default function HeroBar({
|
|||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
КОЭФ. ЛОКАЦИИ
|
||||
ЛОКАЦИЯ
|
||||
</span>
|
||||
<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
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
|
|
@ -583,22 +601,13 @@ export default function HeroBar({
|
|||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{/* #2317: coef is a live feature now (GET /location-coef) — a
|
||||
dash here means unavailable/loading for THIS estimate
|
||||
(unavailable geo_source, no lat/lon, or query pending),
|
||||
never "not built yet", so «скоро» would be stale/false. */}
|
||||
нет данных
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.locationCoef}
|
||||
{/* Distinct honest reasons instead of one blank dash — see
|
||||
mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ"
|
||||
(out_of_coverage — index only covers Yekaterinburg),
|
||||
"мало данных" (insufficient_data — too few comparable
|
||||
listings), "нет данных" (not fetched yet for this
|
||||
estimate). Full explanation lives in the drawer below. */}
|
||||
{data.object.locationIndexLabel}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,33 +1,128 @@
|
|||
"use client";
|
||||
|
||||
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to
|
||||
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||
// design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
|
||||
// a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
||||
// footer — none of which the backend produced at the time (location-coef was
|
||||
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef
|
||||
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI
|
||||
// factor list when available, and an HONEST "недоступно" state (never a fake
|
||||
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale
|
||||
// for this environment, or the estimate has no lat/lon). 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.
|
||||
// footer. That location-coef metric was replaced outright (see
|
||||
// backend/app/services/location_index.py for the full audit): ±5% range,
|
||||
// uncorrelated with real prices, never actually fed the estimate. This drawer
|
||||
// now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
|
||||
// % deviation of the local median ₽/м² from the citywide median — framed as a
|
||||
// comparison metric, explicitly NOT a price adjustment — plus the real
|
||||
// nearest-POI list. The three degraded states (loading / out_of_coverage /
|
||||
// insufficient_data) each get their own honest explanation instead of one
|
||||
// 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 { tokens } from "./tokens";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { LocationData } from "./mappers";
|
||||
|
||||
// Default presentation data (unwired usage / no coef fetched yet): the honest
|
||||
// unavailable state, never a fabricated coefficient.
|
||||
// Default presentation data (unwired usage / not fetched yet): the honest
|
||||
// loading state, never a fabricated coefficient.
|
||||
const LOCATION_FIXTURE: LocationData = {
|
||||
available: false,
|
||||
coefDelta: "—",
|
||||
baseLabel: "—",
|
||||
resultLabel: "—",
|
||||
status: "loading",
|
||||
indexLabel: "—",
|
||||
localMedianLabel: "—",
|
||||
cityMedianLabel: "—",
|
||||
sampleSize: 0,
|
||||
radiusLabel: "—",
|
||||
poiAvailable: false,
|
||||
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 {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -313,142 +408,95 @@ export function LocationDrawer({
|
|||
>
|
||||
ЛОКАЦИЯ
|
||||
</div>
|
||||
{data.available ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
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,
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
{data.status === "ok" && (
|
||||
<>
|
||||
<div
|
||||
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
|
||||
>
|
||||
{data.resultLabel}
|
||||
</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 }}>
|
||||
Объектов инфраструктуры в радиусе поиска не найдено.
|
||||
{locationDirectionSentence(data.indexLabel)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.5,
|
||||
color: tokens.muted3,
|
||||
}}
|
||||
>
|
||||
Ориентировочная поправка по близости инфраструктуры
|
||||
(OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована
|
||||
на реальных ценовых сделках.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "}
|
||||
<b style={{ color: tokens.ink2 }}>недоступны</b> — коэффициент
|
||||
локации не корректирует текущую оценку.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<span>Медиана ₽/м² рядом (радиус {data.radiusLabel})</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||
>
|
||||
{data.localMedianLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<span>Медиана ₽/м² по Екатеринбургу</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||
>
|
||||
{data.cityMedianLabel}
|
||||
</span>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import type {
|
|||
DealRow,
|
||||
DropdownOptions,
|
||||
History,
|
||||
LocationFactors,
|
||||
MarketAds,
|
||||
MarketDeals,
|
||||
ObjectInfo,
|
||||
|
|
@ -44,7 +43,8 @@ export const object: ObjectInfo = {
|
|||
houseType: "Панельный",
|
||||
repair: "Хороший",
|
||||
balcony: true,
|
||||
locationCoef: "0.87",
|
||||
locationIndexLabel: "+8%",
|
||||
locationIndexOk: true,
|
||||
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
||||
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
||||
lat: 56.8384,
|
||||
|
|
@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = {
|
|||
],
|
||||
};
|
||||
|
||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
||||
|
||||
export const locationFactors: LocationFactors = {
|
||||
coef: "0.87",
|
||||
intro:
|
||||
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
||||
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, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
|
||||
};
|
||||
// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
|
||||
// + base/coef/result formula + positives/negatives) lived here — removed
|
||||
// together with the location-index rewrite. LocationDrawer now reads
|
||||
// `LocationData` produced by `mapLocation` (./mappers.ts) from the real
|
||||
// GET /trade-in/location-index response; its own default/unwired fixture is
|
||||
// LOCATION_FIXTURE in ./LocationDrawer.tsx.
|
||||
|
||||
// ---- NAV / CHROME ---------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
// source groupBy) — out of scope for #2040/#2041, left as-is.
|
||||
// BE-2 target_address is a single string; street/city are split heuristically
|
||||
// (parseAddress). Backend should return structured address components.
|
||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
||||
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
||||
// BE-3 location index (replacing the broken location-coef, see
|
||||
// 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):
|
||||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||
|
|
@ -34,7 +35,7 @@ import type {
|
|||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
HouseType,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
PlacementHistoryItem,
|
||||
RepairState,
|
||||
SalesVsListingsResponse,
|
||||
|
|
@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report {
|
|||
}
|
||||
|
||||
/**
|
||||
* Location-coefficient delta label shared by mapObject (HeroBar tile) and
|
||||
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05]
|
||||
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price
|
||||
* deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the
|
||||
* other honest deltas on this page), never a raw multiplier that would read as
|
||||
* more precise than it is. "—" while absent/loading AND when
|
||||
* geo_source="unavailable" (legitimate graceful fallback, not an error).
|
||||
* Compact HeroBar badge for the location index — {label, ok} shared by
|
||||
* mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
|
||||
* reuse. location_index_pct is a real % deviation of the local median ₽/м²
|
||||
* from the citywide median (see backend/app/services/location_index.py) — a
|
||||
* comparison metric, NOT a price multiplier, and it does NOT feed the
|
||||
* estimate. `ok: true` only for status="ok" (a trustworthy percent); every
|
||||
* 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 {
|
||||
if (lc == null || lc.geo_source === "unavailable") return "—";
|
||||
return fmtPct((lc.coef - 1) * 100);
|
||||
function locationIndexBadge(
|
||||
li: LocationIndexResponse | null | undefined,
|
||||
): { 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).
|
||||
* `coef` is the GET /trade-in/location-coef response (#2317) — optional/null
|
||||
* while it is still loading or unavailable for this address.
|
||||
* `locationIndex` is the GET /trade-in/location-index response — optional/null
|
||||
* while it is still loading or not requested for this address.
|
||||
*/
|
||||
export function mapObject(
|
||||
e: AggregatedEstimate,
|
||||
coef?: LocationCoefResponse | null,
|
||||
locationIndex?: LocationIndexResponse | null,
|
||||
): ObjectInfo {
|
||||
const { address, city } = parseAddress(e.target_address);
|
||||
const badge = locationIndexBadge(locationIndex);
|
||||
return {
|
||||
address,
|
||||
city,
|
||||
|
|
@ -817,7 +828,8 @@ export function mapObject(
|
|||
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
||||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||
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 —
|
||||
// null while the estimate has no geocode yet.
|
||||
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
|
||||
// backend/app/services/location_coef.py (top7 straight-line POI score). An
|
||||
// unrecognised category (raw OSM tag outside that dict) falls back to a
|
||||
// backend/app/services/location_index.py (top-N straight-line POI ranking —
|
||||
// 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.
|
||||
const POI_CATEGORY_RU: Record<string, string> = {
|
||||
metro_stop: "Метро",
|
||||
|
|
@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string {
|
|||
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 {
|
||||
label: string; // POI name if known, else its RU category
|
||||
category: string; // RU category label (always present, for the badge)
|
||||
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 {
|
||||
// true when the backend actually computed a coefficient for this address
|
||||
// (geo_source="osm_poi_ekb"), even if no POI were found within radius
|
||||
// (factors=[] is then a legitimate empty result, not "no data").
|
||||
available: boolean;
|
||||
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable
|
||||
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽"
|
||||
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽"
|
||||
status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
|
||||
indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
|
||||
localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
|
||||
cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
|
||||
sampleSize: number; // comparable active listings actually found (0 if n/a)
|
||||
radiusLabel: string; // fmtDist(radius_m), "—" while loading
|
||||
poiAvailable: boolean; // poi_status === "ok" (independent of status above)
|
||||
factors: LocationFactorRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef
|
||||
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest
|
||||
* unavailable state — never a fabricated coefficient, price or factor list
|
||||
* (mirrors the backend's own graceful-fallback contract).
|
||||
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
|
||||
* null/undefined (not fetched yet) degrades to status="loading" — never a
|
||||
* fabricated index, price or factor list (mirrors the backend's own
|
||||
* graceful-fallback contract).
|
||||
*/
|
||||
export function mapLocation(
|
||||
lc: LocationCoefResponse | null | undefined,
|
||||
li: LocationIndexResponse | null | undefined,
|
||||
): LocationData {
|
||||
if (lc == null || lc.geo_source === "unavailable") {
|
||||
if (li == null) {
|
||||
return {
|
||||
available: false,
|
||||
coefDelta: "—",
|
||||
baseLabel: "—",
|
||||
resultLabel: "—",
|
||||
status: "loading",
|
||||
indexLabel: "—",
|
||||
localMedianLabel: "—",
|
||||
cityMedianLabel: "—",
|
||||
sampleSize: 0,
|
||||
radiusLabel: "—",
|
||||
poiAvailable: false,
|
||||
factors: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
coefDelta: coefDeltaLabel(lc),
|
||||
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`,
|
||||
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`,
|
||||
factors: lc.factors.map((f) => ({
|
||||
label: f.name?.trim() || poiCategoryLabel(f.poi_type),
|
||||
category: poiCategoryLabel(f.poi_type),
|
||||
distance: fmtDist(f.distance_m),
|
||||
status: li.status,
|
||||
indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
|
||||
localMedianLabel: fmtPpm(li.local_median_price_per_m2),
|
||||
cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
|
||||
sampleSize: li.sample_size,
|
||||
radiusLabel: fmtDist(li.radius_m),
|
||||
poiAvailable: li.poi_status === "ok",
|
||||
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;
|
||||
repair: string;
|
||||
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
|
||||
// when the estimate has no geocode yet — the map then renders an honest
|
||||
// "нет координат" placeholder instead of an empty/broken box.
|
||||
|
|
@ -287,21 +295,11 @@ export interface DropdownOptions {
|
|||
crm: string[];
|
||||
}
|
||||
|
||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
||||
|
||||
export interface LocationFactor {
|
||||
label: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface LocationFactors {
|
||||
coef: string;
|
||||
intro: string;
|
||||
formula: { base: string; coef: string; result: string };
|
||||
positives: LocationFactor[];
|
||||
negatives: LocationFactor[];
|
||||
footer: string;
|
||||
}
|
||||
// Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/
|
||||
// result formula + positives/negatives) lived here — removed together with
|
||||
// the location-index rewrite: LocationDrawer now consumes `LocationData`
|
||||
// (see ./mappers.ts), which reflects the real backend contract, not the old
|
||||
// fabricated formula.
|
||||
|
||||
// ---- USER / CHROME --------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
PlacementHistoryItem,
|
||||
SalesVsListingsResponse,
|
||||
SellTimeSensitivityResponse,
|
||||
|
|
@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
|
|||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/trade-in/location-coef?estimate_id=&radius_m=
|
||||
* POI-based location coefficient for the estimate's target address (#2045 BE-3
|
||||
* backend, #2317 FE wiring — LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is
|
||||
* an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas.
|
||||
* geo_source="unavailable" is a legitimate graceful-fallback response (local POI
|
||||
* mirror empty/stale on this environment, or the estimate has no lat/lon) —
|
||||
* ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient.
|
||||
* GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
|
||||
* Location index for the estimate's target address — replaces the broken
|
||||
* location-coef (backend rewrite, see app/services/location_index.py):
|
||||
* % deviation of the local median ₽/м² (comparable active listings near the
|
||||
* address) from the citywide median ₽/м², NOT a price multiplier and NOT fed
|
||||
* into the estimate. status="out_of_coverage"/"insufficient_data" are honest
|
||||
* 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();
|
||||
if (estimate_id) params.set("estimate_id", estimate_id);
|
||||
if (radius_m != null) params.set("radius_m", String(radius_m));
|
||||
return useQuery<LocationCoefResponse>({
|
||||
queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null],
|
||||
return useQuery<LocationIndexResponse>({
|
||||
queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
|
||||
queryFn: () =>
|
||||
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`),
|
||||
apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
|
||||
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -470,27 +470,41 @@ export interface TradeInLeadResponse {
|
|||
status: string; // 'received'
|
||||
}
|
||||
|
||||
// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ──
|
||||
// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ.
|
||||
// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py
|
||||
// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05].
|
||||
// result_price_rub = round(base_price_rub * coef).
|
||||
// ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ──
|
||||
// Replaces the broken location-coef (#2045 audit — see
|
||||
// backend/app/services/location_index.py for the full history: the old
|
||||
// `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real
|
||||
// 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
|
||||
// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate
|
||||
// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated.
|
||||
// "osm_poi_ekb" is the normal/real-data source.
|
||||
export interface LocationCoefFactor {
|
||||
// status:
|
||||
// "ok" — location_index_pct/local_median_price_per_m2 reliable.
|
||||
// "out_of_coverage" — address outside the product's geo coverage
|
||||
// (Yekaterinburg only). All numeric fields null — an honest dash, not 0%.
|
||||
// "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"
|
||||
name: string | null; // POI name if known
|
||||
distance_m: number;
|
||||
weight: number; // internal score contribution — NOT a per-POI price delta
|
||||
}
|
||||
|
||||
export interface LocationCoefResponse {
|
||||
coef: number;
|
||||
factors: LocationCoefFactor[];
|
||||
geo_source: string; // "osm_poi_ekb" | "unavailable"
|
||||
base_price_rub: number;
|
||||
result_price_rub: number;
|
||||
export interface LocationIndexResponse {
|
||||
status: "ok" | "out_of_coverage" | "insufficient_data";
|
||||
location_index_pct: number | null;
|
||||
local_median_price_per_m2: number | null;
|
||||
city_median_price_per_m2: number | null;
|
||||
sample_size: number;
|
||||
radius_m: number;
|
||||
nearby_poi: NearbyPoi[];
|
||||
poi_status: "ok" | "unavailable";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue