fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс (#2531)
All checks were successful
Deploy / build-backend (push) Successful in 6m30s
Deploy / build-worker (push) Successful in 6m44s
Deploy / changes (push) Successful in 11s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 2m26s
Deploy Trade-In / deploy (push) Successful in 3m10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / test (push) Successful in 5m5s

This commit is contained in:
lekss361 2026-07-26 21:48:15 +00:00
parent a0647a53a9
commit 580be61914
19 changed files with 1717 additions and 1111 deletions

View file

@ -19,60 +19,110 @@ logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter" OVERPASS_URL = "https://overpass-api.de/api/interpreter"
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east) EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Маппинг OSM-тег → нормализованная category # Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
OSM_CATEGORIES: dict[tuple[str, str], str] = { # category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
# уровне отдельных HTTP-запросов: элемент, подходящий под любую из альтернативных схем
# разметки, попадёт в категорию.
OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
# amenity tags — школы расширены (school/college/university) # amenity tags — школы расширены (school/college/university)
("amenity", "school"): "school", (("amenity", "school"),): "school",
("amenity", "college"): "school", (("amenity", "college"),): "school",
("amenity", "university"): "school", (("amenity", "university"),): "school",
("amenity", "kindergarten"): "kindergarten", (("amenity", "kindergarten"),): "kindergarten",
("amenity", "pharmacy"): "pharmacy", (("amenity", "pharmacy"),): "pharmacy",
("amenity", "hospital"): "hospital", (("amenity", "hospital"),): "hospital",
("amenity", "clinic"): "hospital", (("amenity", "clinic"),): "hospital",
# shop tags — supermarket расширен # shop tags — supermarket расширен
("shop", "mall"): "shop_mall", (("shop", "mall"),): "shop_mall",
("shop", "supermarket"): "shop_supermarket", (("shop", "supermarket"),): "shop_supermarket",
("shop", "hypermarket"): "shop_supermarket", (("shop", "hypermarket"),): "shop_supermarket",
("shop", "convenience"): "shop_small", (("shop", "convenience"),): "shop_small",
("shop", "bakery"): "shop_small", (("shop", "bakery"),): "shop_small",
# leisure # leisure
("leisure", "park"): "park", (("leisure", "park"),): "park",
# transit # transit
("railway", "tram_stop"): "tram_stop", (("railway", "tram_stop"),): "tram_stop",
("highway", "bus_stop"): "bus_stop", (("highway", "bus_stop"),): "bus_stop",
# метро (одна линия в ЕКБ, но добавляем для полноты) # Метро ЕКБ (9 станций, одна линия). Fix (location-index rework): фильтр раньше ловил
("station", "subway"): "metro_stop", # ТОЛЬКО station=subway и подтягивал лишь 5/9 станций — часть станций в OSM размечена
# без ключа "station" вовсе, комбинацией railway=station + subway=yes (альтернативная,
# но распространённая схема разметки метро). Обе схемы — отдельными записями ниже, чтобы
# не терять станции, размеченные любой из них.
(("station", "subway"),): "metro_stop",
(("railway", "station"), ("subway", "yes")): "metro_stop",
} }
def _build_overpass_query_single(key: str, value: str) -> str: def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
"""Запрос для одной пары tag → нормированной категории. """Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
Раньше делали один большой запрос на все 14 категорий Overpass возвращал Раньше делали один большой запрос на все 14 категорий Overpass возвращал
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт 504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
14 быстрых запросов вместо одного 60+ секундного. быстрые запросы вместо одного 60+ секундного.
""" """
south, west, north, east = EKB_BBOX south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})" bbox = f"({south},{west},{north},{east})"
return ( filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
f"[out:json][timeout:30];" return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
f'(node["{key}"="{value}"]{bbox};way["{key}"="{value}"]{bbox};);'
f"out center meta;"
)
def _classify(tags: dict[str, str]) -> str | None: def _classify(tags: dict[str, str]) -> str | None:
"""Определить category из OSM-тегов. None если не соответствует ни одной.""" """Определить category из OSM-тегов. None если не соответствует ни одной."""
for (k, v), cat in OSM_CATEGORIES.items(): for tag_filters, cat in OSM_CATEGORIES.items():
if tags.get(k) == v: if all(tags.get(k) == v for k, v in tag_filters):
return cat return cat
return None return None
def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
return ",".join(f"{k}={v}" for k, v in tag_filters)
async def _fetch_category(
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
) -> list[dict]:
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более
2 попыток на категорию).
"""
tag_desc = _tag_filters_desc(tag_filters)
query = _build_overpass_query(tag_filters)
for attempt in (1, 2):
try:
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info(
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
)
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
for el in elements:
el["_gd_category"] = category
return elements
except Exception as e:
if attempt == 1:
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
await asyncio.sleep(3.0)
continue
logger.warning(
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
)
return []
async def fetch_overpass() -> list[dict]: async def fetch_overpass() -> list[dict]:
"""Запросить Overpass API per category, вернуть combined список elements. """Запросить Overpass API per category, вернуть combined список elements.
Делаем 14 отдельных запросов вместо одного гигантского большой запрос Делаем отдельные запросы вместо одного гигантского большой запрос
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
policy: max 2 concurrent, лучше 1 req/s). policy: max 2 concurrent, лучше 1 req/s).
@ -85,27 +135,14 @@ async def fetch_overpass() -> list[dict]:
} }
all_elements: list[dict] = [] all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=headers) as client: async with httpx.AsyncClient(timeout=60, headers=headers) as client:
for (key, value), category in OSM_CATEGORIES.items(): for tag_filters, category in OSM_CATEGORIES.items():
query = _build_overpass_query_single(key, value) elements = await _fetch_category(client, tag_filters, category)
try: all_elements.extend(elements)
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info("Overpass: %s=%s (%s) → %d", key, value, category, len(elements))
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
for el in elements:
el["_gd_category"] = category
all_elements.extend(elements)
except Exception as e:
# Не падаем на одной категории — логируем и продолжаем
logger.warning("Overpass failed for %s=%s: %s", key, value, e)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
logger.info( logger.info(
"Overpass: total %d elements across %d categories", len(all_elements), len(OSM_CATEGORIES) "Overpass: total %d elements across %d category-queries",
len(all_elements),
len(OSM_CATEGORIES),
) )
return all_elements return all_elements

View 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;

View file

@ -28,8 +28,8 @@ from app.schemas.trade_in import (
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
IMVBenchmarkResponse, IMVBenchmarkResponse,
LocationCoefFactorOut, LocationIndexResponse,
LocationCoefResponse, NearbyPoiOut,
PhotoMeta, PhotoMeta,
PlacementHistoryEntry, PlacementHistoryEntry,
PriceHistoryYearPoint, PriceHistoryYearPoint,
@ -1567,39 +1567,43 @@ def get_estimate_imv_benchmark(
) )
# ── Location-coef POI scoring (#2045 BE-3, LocationDrawer) ──────────────────── # ── Location index (issue TBD, замена сломанного location-coef #2045) ────────
@router.get("/location-coef", response_model=LocationCoefResponse) @router.get("/location-index", response_model=LocationIndexResponse)
def get_location_coef( def get_location_index(
estimate_id: UUID, estimate_id: UUID,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
radius_m: int | None = None, radius_m: int | None = None,
) -> LocationCoefResponse: ) -> LocationIndexResponse:
"""Location-coefficient POI-скоринг для оценки (#2045 BE-3, LocationDrawer). """Location index для оценки (замена сломанного location-coef, LocationDrawer).
Резолвит lat/lon/median_price оценки, считает coef через Резолвит lat/lon оценки, считает индекс через
app.services.location_coef.compute_location_coef straight-line POI weighted score app.services.location_index.compute_location_index: % отклонения медианы /м²
(портировано из Site Finder poi_score.py) поверх локального зеркала osm_poi_ekb_local, сопоставимых активных листингов в радиусе точки от медианы /м² по всему Екатеринбургу
обновляемого scheduler'ом (source=osm_poi_ekb_refresh). result_price_rub = round( (percentile_cont(0.5) устойчиво к выбросам). НЕ участвует в цене estimator.py про
base_price_rub * coef). этот показатель не знает (аналоги уже несут локацию в базовой цене).
404 оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних 404 оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
derived-роутов). radius_m опционален (None DEFAULT_RADIUS_M=1200м, подобран для derived-роутов). radius_m опционален (None адаптивная лестница радиусов
МКД, НЕ Ptica-дефолт 2000м для участков); явное значение клэмпится в [500, 3000]. RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).
Graceful fallback (НЕ 500, НЕ сфабрикованные факторы): Честная деградация (НЕ 500, НЕ сфабрикованные значения) см. LocationIndexResponse:
- osm_poi_ekb_local пуста/не отрефрешена на этом окружении coef=1.0, factors=[], - status="out_of_coverage" у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
geo_source="unavailable" (см. compute_location_coef). (Екатеринбург).
- у оценки нет lat/lon (легаси/geo-fallback не сработал на POST) тот же fallback. - status="insufficient_data" даже на максимальном радиусе сопоставимых активных
листингов меньше порога.
- poi_status="unavailable" osm_poi_ekb_local пуста/не отрефрешена (независимо от
status выше «что рядом» и числовой индекс деградируют раздельно).
""" """
_assert_estimate_access_by_id(db, estimate_id, x_authenticated_user) _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
row = db.execute( row = db.execute(
text( text(
""" """
SELECT lat, lon, median_price SELECT lat, lon
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
@ -1609,34 +1613,38 @@ def get_location_coef(
if row is None: if row is None:
raise HTTPException(status_code=404, detail="estimate not found") raise HTTPException(status_code=404, detail="estimate not found")
base_price_rub = int(row.median_price or 0)
if row.lat is None or row.lon is None: if row.lat is None or row.lon is None:
logger.info("location_coef: estimate=%s has no lat/lon — unavailable fallback", estimate_id) logger.info(
return LocationCoefResponse( "location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
coef=1.0, )
factors=[], return LocationIndexResponse(
geo_source="unavailable", status="out_of_coverage",
base_price_rub=base_price_rub, location_index_pct=None,
result_price_rub=base_price_rub, local_median_price_per_m2=None,
city_median_price_per_m2=None,
sample_size=0,
radius_m=radius_m or 0,
nearby_poi=[],
poi_status="unavailable",
) )
from app.services.location_coef import DEFAULT_RADIUS_M, compute_location_coef from app.services.location_index import compute_location_index
resolved_radius = DEFAULT_RADIUS_M if radius_m is None else max(500, min(radius_m, 3000)) resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
result = compute_location_coef(db, float(row.lat), float(row.lon), radius_m=resolved_radius) result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)
return LocationCoefResponse( return LocationIndexResponse(
coef=result.coef, status=result.status,
factors=[ location_index_pct=result.location_index_pct,
LocationCoefFactorOut( local_median_price_per_m2=result.local_median_price_per_m2,
poi_type=f.poi_type, name=f.name, distance_m=f.distance_m, weight=f.weight city_median_price_per_m2=result.city_median_price_per_m2,
) sample_size=result.sample_size,
for f in result.factors radius_m=result.radius_m,
nearby_poi=[
NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m)
for p in result.nearby_poi
], ],
geo_source=result.geo_source, poi_status=result.poi_status,
base_price_rub=base_price_rub,
result_price_rub=round(base_price_rub * result.coef),
) )

View file

@ -598,29 +598,48 @@ class QuotaStatus(BaseModel):
unlimited: bool # True для admin / kopylov / без заголовка unlimited: bool # True для admin / kopylov / без заголовка
class LocationCoefFactorOut(BaseModel): class NearbyPoiOut(BaseModel):
"""Один POI-фактор в ответе GET /api/v1/trade-in/location-coef (#2045 BE-3).""" """Один пункт «что рядом» в ответе GET /api/v1/trade-in/location-index.
Качественная справка (школа 185 м, остановка 93 м) НЕ участвует в location_index_pct.
"""
poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения, poi_type: str # категория POI (school/kindergarten/metro_stop/... — те же значения,
# что в osm_poi_ekb на стороне gendesign) # что в osm_poi_ekb на стороне gendesign)
name: str | None name: str | None
distance_m: float distance_m: float
weight: float
class LocationCoefResponse(BaseModel): class LocationIndexResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer). """Ответ GET /api/v1/trade-in/location-index — замена сломанного location-coef.
coef MVP-эвристика (НЕ откалибрована на реальных ценовых дельтах, см. ИСТОРИЯ: старый `location-coef` (`coef = 0.95 + score/100*0.10`, range [0.95,1.05],
app/services/location_coef.py::_score_to_coef), диапазон [0.95, 1.05]. `result_price_rub = round(base_price_rub * coef)`) не был откалиброван на ценах 67% из
result_price_rub = round(base_price_rub * coef). 1500 адресов ЕКБ попадали в ±1%, а бакеты coef НЕ монотонны относительно медианы /м² по
4000 активным лотам (дороже не значит выше coef). Полностью заменён.
geo_source="unavailable" osm_poi_ekb_local пуста/не отрефрешена на этом окружении location_index_pct % отклонения медианы /м² сопоставимых активных листингов в радиусе
(graceful fallback: coef=1.0, factors=[], НЕ 500 и НЕ сфабрикованные факторы). точки от медианы /м² по всему Екатеринбургу (см. app/services/location_index.py). НЕ
зажат искусственно диапазон реальный. НЕ участвует в цене (estimator.py про него не
знает: аналоги уже несут локацию в базовой цене, повторное умножение double-count).
status:
- "ok" location_index_pct/local_median_price_per_m2 надёжны.
- "out_of_coverage" точка вне гео-охвата продукта (Екатеринбург). Все числовые
поля None честный прочерк на фронте, НЕ 0%.
- "insufficient_data" даже на максимальном радиусе сопоставимых активных листингов
меньше порога (см. MIN_SAMPLE_SIZE). Числовые поля None, но sample_size/radius_m
показывают, что реально нашлось.
poi_status независимый статус для nearby_poi: "ok" | "unavailable" (osm_poi_ekb_local
ещё не отрефрешена на этом окружении пустой список, НЕ сфабрикованные точки).
""" """
coef: float status: str
factors: list[LocationCoefFactorOut] location_index_pct: float | None
geo_source: str local_median_price_per_m2: int | None
base_price_rub: int city_median_price_per_m2: int | None
result_price_rub: int sample_size: int
radius_m: int
nearby_poi: list[NearbyPoiOut]
poi_status: str

View file

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

View 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,
)

View file

@ -1,4 +1,4 @@
"""OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-coef). """OSM POI local-mirror refresh (#2045 BE-3, LocationDrawer location-index).
Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the Populates `osm_poi_ekb_local` (empty at deploy, migration 169) via a single bulk scan of the
`gendesign_osm_poi_ekb` FDW foreign table (migration 168 live view of gendesign's `gendesign_osm_poi_ekb` FDW foreign table (migration 168 live view of gendesign's
@ -8,9 +8,11 @@ of #2045, already merged + deployed on gendesign).
WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW see WHY a local mirror (perf fact measured for the analogous gendesign_cad_buildings FDW see
`app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW `app/tasks/cadastral_geo_match.py`): a per-request FDW nearest-POI query pays a per-row FDW
round-trip (~1.16s/row without a geom index on the remote table) UNUSABLE for a synchronous round-trip (~1.16s/row without a geom index on the remote table) UNUSABLE for a synchronous
endpoint (`GET /api/v1/trade-in/location-coef`). We materialize the FDW once (single bulk endpoint (`GET /api/v1/trade-in/location-index`). We materialize the FDW once (single bulk
scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then scan) into `osm_poi_ekb_local` with a real Point geom + GIST index, then
`app/services/location_coef.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate. `app/services/location_index.py` runs fast LOCAL ST_DWithin/ST_Distance queries per estimate
(nearby-POI qualitative list only the numeric index itself comes from `listings`, not POI;
see that module's docstring for the location-coef → location-index rewrite history).
Scheduler source='osm_poi_ekb_refresh' (daily OSM POI data changes rarely). Pure internal Scheduler source='osm_poi_ekb_refresh' (daily OSM POI data changes rarely). Pure internal
DB op one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot. DB op one FDW read + local TRUNCATE+INSERT, no HTTP/anti-bot.

View file

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

View 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

View file

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

View 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

View file

@ -60,7 +60,7 @@ import {
useEstimateMutation, useEstimateMutation,
useEstimatePlacementHistory, useEstimatePlacementHistory,
useEstimateSellTimeSensitivity, useEstimateSellTimeSensitivity,
useLocationCoef, useLocationIndex,
useSalesVsListings, useSalesVsListings,
useStreetDeals, useStreetDeals,
} from "@/lib/trade-in-api"; } from "@/lib/trade-in-api";
@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = {
houseType: "—", houseType: "—",
repair: "—", repair: "—",
balcony: false, balcony: false,
locationCoef: "—", locationIndexLabel: "—",
locationIndexOk: false,
lat: null, lat: null,
lon: null, lon: null,
}; };
@ -471,7 +472,7 @@ export default function TradeInV2Page() {
// L3 — once the restore-by-id fetch has confirmed the estimate does not // L3 — once the restore-by-id fetch has confirmed the estimate does not
// exist (404), `currentEstimateId` below drops to null so the sibling // exist (404), `currentEstimateId` below drops to null so the sibling
// dashboard hooks (analytics/location-coef/placement-history/sell-time, // dashboard hooks (analytics/location-index/placement-history/sell-time,
// all `enabled: estimate_id !== null`) don't each fire their own doomed // all `enabled: estimate_id !== null`) don't each fire their own doomed
// request against the same dead id. Hoisted above the sub-hooks (was // request against the same dead id. Hoisted above the sub-hooks (was
// computed further down, after they'd already fired on the stale id). // computed further down, after they'd already fired on the stale id).
@ -532,10 +533,10 @@ export default function TradeInV2Page() {
estimate?.rooms ?? null, estimate?.rooms ?? null,
); );
const analytics = useEstimateHouseAnalytics(currentEstimateId); const analytics = useEstimateHouseAnalytics(currentEstimateId);
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve // LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
// contract: a pending/errored/unavailable response degrades to the mapper's // pending/errored/degraded response resolves to the mapper's own honest,
// honest "—" (mapObject/mapLocation), never a fabricated coefficient. // distinct reason (mapObject/mapLocation) — never a fabricated percent.
const locationCoef = useLocationCoef(currentEstimateId); const locationIndex = useLocationIndex(currentEstimateId);
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
// contract: each resolves independently; a pending/errored one degrades its // contract: each resolves independently; a pending/errored one degrades its
// overlay section to an honest empty via the mapper (null input). // overlay section to an honest empty via the mapper (null input).
@ -549,7 +550,7 @@ export default function TradeInV2Page() {
const streetDealsData = streetDeals.data ?? null; const streetDealsData = streetDeals.data ?? null;
const analyticsData = analytics.data ?? null; const analyticsData = analytics.data ?? null;
const locationCoefData = locationCoef.data ?? null; const locationIndexData = locationIndex.data ?? null;
const placementHistoryData = placementHistory.data ?? null; const placementHistoryData = placementHistory.data ?? null;
const salesVsListingsData = salesVsListings.data ?? null; const salesVsListingsData = salesVsListings.data ?? null;
const sellTimeData = sellTime.data ?? null; const sellTimeData = sellTime.data ?? null;
@ -575,12 +576,12 @@ export default function TradeInV2Page() {
[estimate], [estimate],
); );
const objectInfo = useMemo( const objectInfo = useMemo(
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT), () => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
[estimate, locationCoefData], [estimate, locationIndexData],
); );
const locationData = useMemo( const locationData = useMemo(
() => mapLocation(locationCoefData), () => mapLocation(locationIndexData),
[locationCoefData], [locationIndexData],
); );
const resultPanelData = useMemo( const resultPanelData = useMemo(
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null), () => (estimate ? mapResultPanel(estimate, streetDealsData) : null),

View file

@ -194,7 +194,7 @@ interface HeroBarProps {
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width // #2275 mobile quick-view: a real fluid layout instead of the fixed-width
// desktop one — meta/buttons stack, the locator mini-map (and the // desktop one — meta/buttons stack, the locator mini-map (and the
// address/coef card baked into it) is dropped since it assumes a 560×152 box // address/coef card baked into it) is dropped since it assumes a 560×152 box
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index
// drawer, so no functionality is lost, only the redundant map-card copy. // drawer, so no functionality is lost, only the redundant map-card copy.
compact?: boolean; compact?: boolean;
} }
@ -441,7 +441,7 @@ export default function HeroBar({
the box is a fixed 560×152 with several absolutely-positioned the box is a fixed 560×152 with several absolutely-positioned
children (address card) pinned to that size, so it cannot reflow to children (address card) pinned to that size, so it cannot reflow to
a phone width. «КАК РАССЧИТАНО» above still opens the same a phone width. «КАК РАССЧИТАНО» above still opens the same
location-coef drawer, so no functionality is lost. location-index drawer, so no functionality is lost.
User-reported bug: this used to be a single static building.png User-reported bug: this used to be a single static building.png
photo shown for EVERY estimate regardless of the real address (a photo shown for EVERY estimate regardless of the real address (a
user could be looking at someone else's building) replaced with a user could be looking at someone else's building) replaced with a
@ -547,7 +547,14 @@ export default function HeroBar({
onOpenInfo(); onOpenInfo();
} }
}} }}
aria-label="Пояснение к расчёту коэффициента локации" // Honest framing (post location-coef rewrite, see
// backend/app/services/location_index.py): this is a comparison
// vs. the city median, not a price multiplier, and it never
// affects the quoted estimate. title= is a plain hover tooltip
// (zero layout cost) carrying that caveat since the compact pill
// has no room to spell it out inline.
aria-label="Локация относительно города — справочно, не влияет на оценку"
title="Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет."
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -566,10 +573,21 @@ export default function HeroBar({
color: tokens.muted2, color: tokens.muted2,
}} }}
> >
КОЭФ. ЛОКАЦИИ ЛОКАЦИЯ
</span> </span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}> <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
{data.object.locationCoef === "—" ? ( {data.object.locationIndexOk ? (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationIndexLabel}
</span>
) : (
<span <span
style={{ style={{
fontSize: "9px", fontSize: "9px",
@ -583,22 +601,13 @@ export default function HeroBar({
padding: "1px 8px", padding: "1px 8px",
}} }}
> >
{/* #2317: coef is a live feature now (GET /location-coef) a {/* Distinct honest reasons instead of one blank dash see
dash here means unavailable/loading for THIS estimate mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ"
(unavailable geo_source, no lat/lon, or query pending), (out_of_coverage index only covers Yekaterinburg),
never "not built yet", so «скоро» would be stale/false. */} "мало данных" (insufficient_data too few comparable
нет данных listings), "нет данных" (not fetched yet for this
</span> estimate). Full explanation lives in the drawer below. */}
) : ( {data.object.locationIndexLabel}
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span> </span>
)} )}
<span <span

View file

@ -1,33 +1,128 @@
"use client"; "use client";
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка" // "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to // design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result" // a FABRICATED location coefficient (0.87), a fake "base × coef = result"
// formula and invented POI factor lists with a false "Источник: OpenStreetMap" // formula and invented POI factor lists with a false "Источник: OpenStreetMap"
// footer — none of which the backend produced at the time (location-coef was // footer. That location-coef metric was replaced outright (see
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef // backend/app/services/location_index.py for the full audit): ±5% range,
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI // uncorrelated with real prices, never actually fed the estimate. This drawer
// factor list when available, and an HONEST "недоступно" state (never a fake // now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale // % deviation of the local median ₽/м² from the citywide median — framed as a
// for this environment, or the estimate has no lat/lon). Keeps the same drawer // comparison metric, explicitly NOT a price adjustment — plus the real
// shell / slide animation / close button. Open/close is driven entirely by // nearest-POI list. The three degraded states (loading / out_of_coverage /
// props; while open it is a real modal dialog (role=dialog/aria-modal, // insufficient_data) each get their own honest explanation instead of one
// focus-trap, Esc) with semantics mirrored from SectionOverlay. // blank "недоступно". Keeps the same drawer shell / slide animation / close
// button. Open/close is driven entirely by props; while open it is a real
// modal dialog (role=dialog/aria-modal, focus-trap, Esc) with semantics
// mirrored from SectionOverlay.
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { pluralRu } from "./mappers";
import type { LocationData } from "./mappers"; import type { LocationData } from "./mappers";
// Default presentation data (unwired usage / no coef fetched yet): the honest // Default presentation data (unwired usage / not fetched yet): the honest
// unavailable state, never a fabricated coefficient. // loading state, never a fabricated coefficient.
const LOCATION_FIXTURE: LocationData = { const LOCATION_FIXTURE: LocationData = {
available: false, status: "loading",
coefDelta: "—", indexLabel: "—",
baseLabel: "—", localMedianLabel: "—",
resultLabel: "—", cityMedianLabel: "—",
sampleSize: 0,
radiusLabel: "—",
poiAvailable: false,
factors: [], factors: [],
}; };
// data.indexLabel is already the signed, rounded fmtPct string produced by
// mapLocation ("+12%" / "8%" / "0%" / "—") — reusing it here (rather than a
// second raw-number field) keeps the sign/rounding logic in one place
// (./mappers.ts). Turns it into a plain-language comparison sentence instead
// of a bare percent, so it reads as "vs. the city", never as a price change.
function locationDirectionSentence(indexLabel: string): string {
if (indexLabel.startsWith("")) {
return `Район дешевле города на ${indexLabel.slice(1)}`;
}
if (indexLabel.startsWith("+")) {
return `Район дороже города на ${indexLabel.slice(1)}`;
}
if (indexLabel === "0%") return "Район на уровне медианы по городу";
return "—";
}
// «Что рядом» — qualitative POI list, independent of the numeric index
// (poi_status degrades separately from status, see mapLocation/./mappers.ts).
function PoiSection({ data }: { data: LocationData }) {
if (!data.poiAvailable) {
return (
<div style={{ marginTop: 12, fontSize: 11.5, color: tokens.muted3 }}>
Данные о ближайшей инфраструктуре сейчас недоступны.
</div>
);
}
return (
<>
<div
style={{
marginTop: 12,
marginBottom: 6,
fontSize: 10,
letterSpacing: "1px",
color: tokens.muted2,
}}
>
ЧТО РЯДОМ
</div>
{data.factors.length > 0 ? (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{data.factors.map((f, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
}}
>
<span style={{ color: tokens.ink2 }}>
{f.label}
{f.category !== f.label && (
<span style={{ color: tokens.muted3 }}> · {f.category}</span>
)}
</span>
<span
style={{
flex: "0 0 auto",
color: tokens.muted,
fontFamily: tokens.font.mono,
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div>
)}
</>
);
}
interface LocationDrawerProps { interface LocationDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@ -313,142 +408,95 @@ export function LocationDrawer({
> >
ЛОКАЦИЯ ЛОКАЦИЯ
</div> </div>
{data.available ? ( <div
<div style={{
style={{ fontSize: 12.5,
fontSize: 12.5, lineHeight: 1.7,
lineHeight: 1.7, color: tokens.body2,
color: tokens.body2, background: tokens.infoSoftBg,
background: tokens.infoSoftBg, border: `1px solid ${tokens.infoSoftBorder}`,
border: `1px solid ${tokens.infoSoftBorder}`, borderRadius: 7,
borderRadius: 7, padding: "12px 14px",
padding: "12px 14px", }}
}} >
> {data.status === "ok" && (
<div <>
style={{ <div
display: "flex", style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
alignItems: "baseline",
justifyContent: "space-between",
}}
>
<span>Без поправки на локацию</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}>
{data.baseLabel}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginTop: 4,
marginBottom: data.factors.length > 0 ? 10 : 0,
}}
>
<span>С поправкой на локацию ({data.coefDelta})</span>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
fontWeight: 600,
color: tokens.accent,
}}
> >
{data.resultLabel} {locationDirectionSentence(data.indexLabel)}
</span>
</div>
{/* Fix #7c (audit): the estimator never reads this coefficient it's
illustrative/reference only. Without this line the -vs- layout
above reads as if it changes the quoted price. */}
<div
style={{
marginTop: 8,
fontSize: 10.5,
lineHeight: 1.5,
color: tokens.muted3,
}}
>
Справочно: поправка на локацию не влияет на итоговую оценку.
</div>
{data.factors.length > 0 ? (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{data.factors.map((f, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
}}
>
<span style={{ color: tokens.ink2 }}>
{f.label}
{f.category !== f.label && (
<span style={{ color: tokens.muted3 }}>
{" "}
· {f.category}
</span>
)}
</span>
<span
style={{
flex: "0 0 auto",
color: tokens.muted,
fontFamily: tokens.font.mono,
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div> </div>
)} <div
<div style={{
style={{ display: "flex",
marginTop: 12, alignItems: "baseline",
fontSize: 10.5, justifyContent: "space-between",
lineHeight: 1.5, marginTop: 10,
color: tokens.muted3, }}
}} >
> <span>Медиана /м² рядом (радиус {data.radiusLabel})</span>
Ориентировочная поправка по близости инфраструктуры <span
(OpenStreetMap) MVP-эвристика, диапазон ±5%, не откалибрована style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
на реальных ценовых сделках. >
</div> {data.localMedianLabel}
</div> </span>
) : ( </div>
<div <div
style={{ style={{
fontSize: 12.5, display: "flex",
lineHeight: 1.7, alignItems: "baseline",
color: tokens.body2, justifyContent: "space-between",
background: tokens.infoSoftBg, marginTop: 4,
border: `1px solid ${tokens.infoSoftBorder}`, }}
borderRadius: 7, >
padding: "12px 14px", <span>Медиана /м² по Екатеринбургу</span>
}} <span
> style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "} >
<b style={{ color: tokens.ink2 }}>недоступны</b> коэффициент {data.cityMedianLabel}
локации не корректирует текущую оценку. </span>
</div> </div>
)} {/* Honest, not illustrative: this metric никогда не идёт в цену
(аналоги уже берутся из этого же района повторный учёт
локации был бы задвоением, см. app/services/location_index.py). */}
<div
style={{
marginTop: 8,
fontSize: 10.5,
lineHeight: 1.5,
color: tokens.muted3,
}}
>
Посчитано по {data.sampleSize}{" "}
{pluralRu(data.sampleSize, [
"объявлению",
"объявлениям",
"объявлениям",
])}{" "}
в радиусе {data.radiusLabel}. Справочно на итоговую оценку не
влияет: аналоги для расчёта уже берутся из этого района.
</div>
<PoiSection data={data} />
</>
)}
{data.status === "out_of_coverage" && (
<>
Локационный индекс считаем только по{" "}
<b style={{ color: tokens.ink2 }}>Екатеринбургу</b> этот адрес
вне зоны покрытия, сравнение с городом недоступно.
</>
)}
{data.status === "insufficient_data" && (
<>
Рядом нашлось только{" "}
<b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
сопоставимых объявлений (радиус {data.radiusLabel}) этого
недостаточно для надёжного сравнения с городом.
<PoiSection data={data} />
</>
)}
{data.status === "loading" && "Считаем локационный индекс…"}
</div>
</div> </div>
</> </>
); );

View file

@ -13,7 +13,6 @@ import type {
DealRow, DealRow,
DropdownOptions, DropdownOptions,
History, History,
LocationFactors,
MarketAds, MarketAds,
MarketDeals, MarketDeals,
ObjectInfo, ObjectInfo,
@ -44,7 +43,8 @@ export const object: ObjectInfo = {
houseType: "Панельный", houseType: "Панельный",
repair: "Хороший", repair: "Хороший",
balcony: true, balcony: true,
locationCoef: "0.87", locationIndexLabel: "+8%",
locationIndexOk: true,
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
// fixture coordinate for the HeroBar locator mini-map (unwired usage only). // fixture coordinate for the HeroBar locator mini-map (unwired usage only).
lat: 56.8384, lat: 56.8384,
@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = {
], ],
}; };
// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- // Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
// + base/coef/result formula + positives/negatives) lived here — removed
export const locationFactors: LocationFactors = { // together with the location-index rewrite. LocationDrawer now reads
coef: "0.87", // `LocationData` produced by `mapLocation` (./mappers.ts) from the real
intro: // GET /trade-in/location-index response; its own default/unwired fixture is
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", // LOCATION_FIXTURE in ./LocationDrawer.tsx.
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
positives: [
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
{ label: "Транспортные узлы и остановки рядом", delta: "+0.05" },
{ label: "Набережная и парк в 10 минутах", delta: "+0.04" },
{ label: "Развитая торговая инфраструктура", delta: "+0.03" },
],
negatives: [
{ label: "Оживлённая магистраль, шумовая нагрузка", delta: "0.07" },
{ label: "Дефицит парковочных мест", delta: "0.05" },
{ label: "Возраст жилого фонда района (1985)", delta: "0.04" },
{ label: "Износ инженерных сетей квартала", delta: "0.02" },
],
footer:
"Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
};
// ---- NAV / CHROME --------------------------------------------------------- // ---- NAV / CHROME ---------------------------------------------------------

View file

@ -18,8 +18,9 @@
// source groupBy) — out of scope for #2040/#2041, left as-is. // source groupBy) — out of scope for #2040/#2041, left as-is.
// BE-2 target_address is a single string; street/city are split heuristically // BE-2 target_address is a single string; street/city are split heuristically
// (parseAddress). Backend should return structured address components. // (parseAddress). Backend should return structured address components.
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here // BE-3 location index (replacing the broken location-coef, see
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). // backend/app/services/location_index.py) is wired here
// (mapObject/mapLocation consume GET /trade-in/location-index).
// //
// Enum <-> RU reconciliation (design dropdowns have options with no enum value): // Enum <-> RU reconciliation (design dropdowns have options with no enum value):
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
@ -34,7 +35,7 @@ import type {
HouseAnalyticsKpi, HouseAnalyticsKpi,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseType, HouseType,
LocationCoefResponse, LocationIndexResponse,
PlacementHistoryItem, PlacementHistoryItem,
RepairState, RepairState,
SalesVsListingsResponse, SalesVsListingsResponse,
@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report {
} }
/** /**
* Location-coefficient delta label shared by mapObject (HeroBar tile) and * Compact HeroBar badge for the location index {label, ok} shared by
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05] * mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price * reuse. location_index_pct is a real % deviation of the local median /м²
* deltas) surfaced as a whole-percent delta via fmtPct (same rounding as the * from the citywide median (see backend/app/services/location_index.py) a
* other honest deltas on this page), never a raw multiplier that would read as * comparison metric, NOT a price multiplier, and it does NOT feed the
* more precise than it is. "—" while absent/loading AND when * estimate. `ok: true` only for status="ok" (a trustworthy percent); every
* geo_source="unavailable" (legitimate graceful fallback, not an error). * other case gets a short, honest, DISTINCT reason instead of a blank dash
* the drawer (mapLocation below) expands on each:
* - li == null "нет данных" (not fetched yet / this estimate
* has no location index request enabled)
* - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" "мало данных" (too few comparable listings)
*/ */
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string { function locationIndexBadge(
if (lc == null || lc.geo_source === "unavailable") return "—"; li: LocationIndexResponse | null | undefined,
return fmtPct((lc.coef - 1) * 100); ): { label: string; ok: boolean } {
if (li == null) return { label: "нет данных", ok: false };
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data"
} }
/** /**
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). * Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
* `coef` is the GET /trade-in/location-coef response (#2317) optional/null * `locationIndex` is the GET /trade-in/location-index response optional/null
* while it is still loading or unavailable for this address. * while it is still loading or not requested for this address.
*/ */
export function mapObject( export function mapObject(
e: AggregatedEstimate, e: AggregatedEstimate,
coef?: LocationCoefResponse | null, locationIndex?: LocationIndexResponse | null,
): ObjectInfo { ): ObjectInfo {
const { address, city } = parseAddress(e.target_address); const { address, city } = parseAddress(e.target_address);
const badge = locationIndexBadge(locationIndex);
return { return {
address, address,
city, city,
@ -817,7 +828,8 @@ export function mapObject(
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—", houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
balcony: e.has_balcony ?? false, balcony: e.has_balcony ?? false,
locationCoef: coefDeltaLabel(coef), locationIndexLabel: badge.label,
locationIndexOk: badge.ok,
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use — // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
// null while the estimate has no geocode yet. // null while the estimate has no geocode yet.
lat: e.target_lat, lat: e.target_lat,
@ -825,10 +837,11 @@ export function mapObject(
}; };
} }
// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────── // ── Location index (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────────
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in // RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
// backend/app/services/location_coef.py (top7 straight-line POI score). An // backend/app/services/location_index.py (top-N straight-line POI ranking —
// unrecognised category (raw OSM tag outside that dict) falls back to a // a qualitative "what's nearby" list only, no longer feeding any score/coef).
// An unrecognised category (raw OSM tag outside that dict) falls back to a
// generic label rather than surfacing a raw enum-ish string to the user. // generic label rather than surfacing a raw enum-ish string to the user.
const POI_CATEGORY_RU: Record<string, string> = { const POI_CATEGORY_RU: Record<string, string> = {
metro_stop: "Метро", metro_stop: "Метро",
@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string {
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK; return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
} }
/** One POI row in the LocationDrawer factor list. */ /** One POI row in the LocationDrawer «что рядом» list. */
export interface LocationFactorRow { export interface LocationFactorRow {
label: string; // POI name if known, else its RU category label: string; // POI name if known, else its RU category
category: string; // RU category label (always present, for the badge) category: string; // RU category label (always present, for the badge)
distance: string; // "150 м" / "1.2 км" distance: string; // "150 м" / "1.2 км"
} }
/**
* LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own
* three-way honest-degradation contract ("loading" is an FE-only 4th state
* for li == null, e.g. query still pending) so the drawer can explain EACH
* case differently instead of collapsing them into one dash:
* - "ok" index/medians reliable, show the real numbers.
* - "out_of_coverage" address outside Yekaterinburg the index simply
* doesn't cover it (not an error, not "no data").
* - "insufficient_data" too few comparable listings even at the widest
* radius sampleSize/radiusLabel still say what was actually found.
* - "loading" not fetched yet.
* indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the
* corresponding backend field is null (never a fabricated number).
*/
export interface LocationData { export interface LocationData {
// true when the backend actually computed a coefficient for this address status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
// (geo_source="osm_poi_ekb"), even if no POI were found within radius indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
// (factors=[] is then a legitimate empty result, not "no data"). localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
available: boolean; cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable sampleSize: number; // comparable active listings actually found (0 if n/a)
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽" radiusLabel: string; // fmtDist(radius_m), "—" while loading
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽" poiAvailable: boolean; // poi_status === "ok" (independent of status above)
factors: LocationFactorRow[]; factors: LocationFactorRow[];
} }
/** /**
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest * null/undefined (not fetched yet) degrades to status="loading" never a
* unavailable state never a fabricated coefficient, price or factor list * fabricated index, price or factor list (mirrors the backend's own
* (mirrors the backend's own graceful-fallback contract). * graceful-fallback contract).
*/ */
export function mapLocation( export function mapLocation(
lc: LocationCoefResponse | null | undefined, li: LocationIndexResponse | null | undefined,
): LocationData { ): LocationData {
if (lc == null || lc.geo_source === "unavailable") { if (li == null) {
return { return {
available: false, status: "loading",
coefDelta: "—", indexLabel: "—",
baseLabel: "—", localMedianLabel: "—",
resultLabel: "—", cityMedianLabel: "—",
sampleSize: 0,
radiusLabel: "—",
poiAvailable: false,
factors: [], factors: [],
}; };
} }
return { return {
available: true, status: li.status,
coefDelta: coefDeltaLabel(lc), indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`, localMedianLabel: fmtPpm(li.local_median_price_per_m2),
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`, cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
factors: lc.factors.map((f) => ({ sampleSize: li.sample_size,
label: f.name?.trim() || poiCategoryLabel(f.poi_type), radiusLabel: fmtDist(li.radius_m),
category: poiCategoryLabel(f.poi_type), poiAvailable: li.poi_status === "ok",
distance: fmtDist(f.distance_m), factors: li.nearby_poi.map((p) => ({
label: p.name?.trim() || poiCategoryLabel(p.poi_type),
category: poiCategoryLabel(p.poi_type),
distance: fmtDist(p.distance_m),
})), })),
}; };
} }

View file

@ -18,7 +18,15 @@ export interface ObjectInfo {
houseType: string; houseType: string;
repair: string; repair: string;
balcony: boolean; balcony: boolean;
locationCoef: string; // Compact HeroBar badge for the location index (see mapObject / mappers.ts
// locationIndexBadge): "+12%"/"8%" when the backend has a reliable value
// (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" /
// "нет данных" while loading) — never a fabricated percent.
locationIndexLabel: string;
// true only when locationIndexLabel is a real percent (status="ok") — tells
// HeroBar whether to render it as the accent numeric value or as the muted
// unavailable-reason pill.
locationIndexOk: boolean;
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null // Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
// when the estimate has no geocode yet — the map then renders an honest // when the estimate has no geocode yet — the map then renders an honest
// "нет координат" placeholder instead of an empty/broken box. // "нет координат" placeholder instead of an empty/broken box.
@ -287,21 +295,11 @@ export interface DropdownOptions {
crm: string[]; crm: string[];
} }
// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- // Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/
// result formula + positives/negatives) lived here — removed together with
export interface LocationFactor { // the location-index rewrite: LocationDrawer now consumes `LocationData`
label: string; // (see ./mappers.ts), which reflects the real backend contract, not the old
delta: string; // fabricated formula.
}
export interface LocationFactors {
coef: string;
intro: string;
formula: { base: string; coef: string; result: string };
positives: LocationFactor[];
negatives: LocationFactor[];
footer: string;
}
// ---- USER / CHROME -------------------------------------------------------- // ---- USER / CHROME --------------------------------------------------------

View file

@ -12,7 +12,7 @@ import type {
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
IMVBenchmarkResponse, IMVBenchmarkResponse,
LocationCoefResponse, LocationIndexResponse,
PlacementHistoryItem, PlacementHistoryItem,
SalesVsListingsResponse, SalesVsListingsResponse,
SellTimeSensitivityResponse, SellTimeSensitivityResponse,
@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
} }
/** /**
* GET /api/v1/trade-in/location-coef?estimate_id=&radius_m= * GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
* POI-based location coefficient for the estimate's target address (#2045 BE-3 * Location index for the estimate's target address replaces the broken
* backend, #2317 FE wiring LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is * location-coef (backend rewrite, see app/services/location_index.py):
* an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas. * % deviation of the local median /м² (comparable active listings near the
* geo_source="unavailable" is a legitimate graceful-fallback response (local POI * address) from the citywide median /м², NOT a price multiplier and NOT fed
* mirror empty/stale on this environment, or the estimate has no lat/lon) * into the estimate. status="out_of_coverage"/"insufficient_data" are honest
* ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient. * graceful-fallback responses (address outside Yekaterinburg / too few
* comparables) ./v2/mappers.ts renders each distinctly, never a fabricated
* number.
*/ */
export function useLocationCoef(estimate_id: string | null, radius_m?: number) { export function useLocationIndex(estimate_id: string | null, radius_m?: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (estimate_id) params.set("estimate_id", estimate_id); if (estimate_id) params.set("estimate_id", estimate_id);
if (radius_m != null) params.set("radius_m", String(radius_m)); if (radius_m != null) params.set("radius_m", String(radius_m));
return useQuery<LocationCoefResponse>({ return useQuery<LocationIndexResponse>({
queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null], queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
queryFn: () => queryFn: () =>
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`), apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
enabled: estimate_id !== null && estimate_id.length > 0, enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });

View file

@ -470,27 +470,41 @@ export interface TradeInLeadResponse {
status: string; // 'received' status: string; // 'received'
} }
// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ── // ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ──
// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ. // Replaces the broken location-coef (#2045 audit — see
// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py // backend/app/services/location_index.py for the full history: the old
// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05]. // `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real
// result_price_rub = round(base_price_rub * coef). // prices, and never actually fed the estimate). location_index_pct is the %
// deviation of the local median ₽/м² (comparable active listings near the
// address) from the citywide median ₽/м² — a real, uncalibrated-range
// comparison metric, NOT a price multiplier. It does NOT feed the estimate
// (analogs already carry location in the base price; folding this in again
// would double-count the same effect).
// //
// geo_source="unavailable" is a legitimate graceful-fallback response (the local // status:
// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate // "ok" — location_index_pct/local_median_price_per_m2 reliable.
// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated. // "out_of_coverage" — address outside the product's geo coverage
// "osm_poi_ekb" is the normal/real-data source. // (Yekaterinburg only). All numeric fields null — an honest dash, not 0%.
export interface LocationCoefFactor { // "insufficient_data" — even at the widest search radius there are too few
// comparable active listings. Numeric fields null; sample_size/radius_m
// still report what was actually found.
//
// poi_status is independent of status above ("что рядом" and the numeric
// index degrade separately): "ok" | "unavailable" (local OSM POI mirror
// empty/not yet refreshed on this environment — never fabricated points).
export interface NearbyPoi {
poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten" poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten"
name: string | null; // POI name if known name: string | null; // POI name if known
distance_m: number; distance_m: number;
weight: number; // internal score contribution — NOT a per-POI price delta
} }
export interface LocationCoefResponse { export interface LocationIndexResponse {
coef: number; status: "ok" | "out_of_coverage" | "insufficient_data";
factors: LocationCoefFactor[]; location_index_pct: number | null;
geo_source: string; // "osm_poi_ekb" | "unavailable" local_median_price_per_m2: number | null;
base_price_rub: number; city_median_price_per_m2: number | null;
result_price_rub: number; sample_size: number;
radius_m: number;
nearby_poi: NearbyPoi[];
poi_status: "ok" | "unavailable";
} }