From ec7fd9c02caf0c8e50924cd2ff2474dace42a9ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 26 Jul 2026 23:32:03 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=B4=20location-index=20=E2=80=94=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B1=D0=B5=D1=89?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20=D1=86=D0=B5=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат», из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала: estimator.py про location_coef не знает вовсе. Пара удалена. Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент» подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2 района и города. На итоговую оценку не влияет.» Три состояния недоступности теперь различимы вместо одного прочерка: «вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных» (сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние объяснено человеческим текстом, а при status=ok показано, по скольким объявлениям и в каком радиусе посчитано. Список «что рядом» сохранён как качественная справка, weight из ответа убран (был внутренней ранжирующей величиной, клиенту не значил ничего). --- tradein-mvp/frontend/src/app/v2/page.tsx | 25 +- .../src/components/trade-in/v2/HeroBar.tsx | 51 +-- .../components/trade-in/v2/LocationDrawer.tsx | 350 ++++++++++-------- .../src/components/trade-in/v2/fixtures.ts | 32 +- .../src/components/trade-in/v2/mappers.ts | 125 ++++--- .../src/components/trade-in/v2/types.ts | 30 +- tradein-mvp/frontend/src/lib/trade-in-api.ts | 26 +- tradein-mvp/frontend/src/types/trade-in.ts | 48 ++- 8 files changed, 388 insertions(+), 299 deletions(-) diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 91f13305..2aad1fc1 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -60,7 +60,7 @@ import { useEstimateMutation, useEstimatePlacementHistory, useEstimateSellTimeSensitivity, - useLocationCoef, + useLocationIndex, useSalesVsListings, useStreetDeals, } from "@/lib/trade-in-api"; @@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = { houseType: "—", repair: "—", balcony: false, - locationCoef: "—", + locationIndexLabel: "—", + locationIndexOk: false, lat: null, lon: null, }; @@ -471,7 +472,7 @@ export default function TradeInV2Page() { // L3 — once the restore-by-id fetch has confirmed the estimate does not // exist (404), `currentEstimateId` below drops to null so the sibling - // dashboard hooks (analytics/location-coef/placement-history/sell-time, + // dashboard hooks (analytics/location-index/placement-history/sell-time, // all `enabled: estimate_id !== null`) don't each fire their own doomed // request against the same dead id. Hoisted above the sub-hooks (was // computed further down, after they'd already fired on the stale id). @@ -532,10 +533,10 @@ export default function TradeInV2Page() { estimate?.rooms ?? null, ); const analytics = useEstimateHouseAnalytics(currentEstimateId); - // LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve - // contract: a pending/errored/unavailable response degrades to the mapper's - // honest "—" (mapObject/mapLocation), never a fabricated coefficient. - const locationCoef = useLocationCoef(currentEstimateId); + // LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a + // pending/errored/degraded response resolves to the mapper's own honest, + // distinct reason (mapObject/mapLocation) — never a fabricated percent. + const locationIndex = useLocationIndex(currentEstimateId); // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same // contract: each resolves independently; a pending/errored one degrades its // overlay section to an honest empty via the mapper (null input). @@ -549,7 +550,7 @@ export default function TradeInV2Page() { const streetDealsData = streetDeals.data ?? null; const analyticsData = analytics.data ?? null; - const locationCoefData = locationCoef.data ?? null; + const locationIndexData = locationIndex.data ?? null; const placementHistoryData = placementHistory.data ?? null; const salesVsListingsData = salesVsListings.data ?? null; const sellTimeData = sellTime.data ?? null; @@ -575,12 +576,12 @@ export default function TradeInV2Page() { [estimate], ); const objectInfo = useMemo( - () => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT), - [estimate, locationCoefData], + () => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT), + [estimate, locationIndexData], ); const locationData = useMemo( - () => mapLocation(locationCoefData), - [locationCoefData], + () => mapLocation(locationIndexData), + [locationIndexData], ); const resultPanelData = useMemo( () => (estimate ? mapResultPanel(estimate, streetDealsData) : null), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index 069602b9..6cc50b63 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -194,7 +194,7 @@ interface HeroBarProps { // #2275 mobile quick-view: a real fluid layout instead of the fixed-width // desktop one — meta/buttons stack, the locator mini-map (and the // address/coef card baked into it) is dropped since it assumes a 560×152 box - // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef + // that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index // drawer, so no functionality is lost, only the redundant map-card copy. compact?: boolean; } @@ -441,7 +441,7 @@ export default function HeroBar({ the box is a fixed 560×152 with several absolutely-positioned children (address card) pinned to that size, so it cannot reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same - location-coef drawer, so no functionality is lost. + location-index drawer, so no functionality is lost. User-reported bug: this used to be a single static building.png photo shown for EVERY estimate regardless of the real address (a user could be looking at someone else's building) — replaced with a @@ -535,7 +535,14 @@ export default function HeroBar({ onOpenInfo(); } }} - aria-label="Пояснение к расчёту коэффициента локации" + // Honest framing (post location-coef rewrite, see + // backend/app/services/location_index.py): this is a comparison + // vs. the city median, not a price multiplier, and it never + // affects the quoted estimate. title= is a plain hover tooltip + // (zero layout cost) carrying that caveat since the compact pill + // has no room to spell it out inline. + aria-label="Локация относительно города — справочно, не влияет на оценку" + title="Сравнение медианы ₽/м² района и города. На итоговую оценку не влияет." style={{ display: "flex", alignItems: "center", @@ -554,10 +561,21 @@ export default function HeroBar({ color: tokens.muted2, }} > - КОЭФ. ЛОКАЦИИ + ЛОКАЦИЯ - {data.object.locationCoef === "—" ? ( + {data.object.locationIndexOk ? ( + + {data.object.locationIndexLabel} + + ) : ( - {/* #2317: coef is a live feature now (GET /location-coef) — a - dash here means unavailable/loading for THIS estimate - (unavailable geo_source, no lat/lon, or query pending), - never "not built yet", so «скоро» would be stale/false. */} - нет данных - - ) : ( - - {data.object.locationCoef} + {/* Distinct honest reasons instead of one blank dash — see + mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ" + (out_of_coverage — index only covers Yekaterinburg), + "мало данных" (insufficient_data — too few comparable + listings), "нет данных" (not fetched yet for this + estimate). Full explanation lives in the drawer below. */} + {data.object.locationIndexLabel} )} + Данные о ближайшей инфраструктуре сейчас недоступны. + + ); + } + return ( + <> +
+ ЧТО РЯДОМ +
+ {data.factors.length > 0 ? ( +
    + {data.factors.map((f, i) => ( +
  • + + {f.label} + {f.category !== f.label && ( + · {f.category} + )} + + + {f.distance} + +
  • + ))} +
+ ) : ( +
+ Объектов инфраструктуры в радиусе поиска не найдено. +
+ )} + + ); +} + interface LocationDrawerProps { open: boolean; onClose: () => void; @@ -313,142 +408,95 @@ export function LocationDrawer({ > ЛОКАЦИЯ - {data.available ? ( -
-
- Без поправки на локацию - - {data.baseLabel} - -
-
0 ? 10 : 0, - }} - > - С поправкой на локацию ({data.coefDelta}) - + {data.status === "ok" && ( + <> +
- {data.resultLabel} - -
- {/* 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. */} -
- Справочно: поправка на локацию не влияет на итоговую оценку. -
- {data.factors.length > 0 ? ( -
    - {data.factors.map((f, i) => ( -
  • - - {f.label} - {f.category !== f.label && ( - - {" "} - · {f.category} - - )} - - - {f.distance} - -
  • - ))} -
- ) : ( -
- Объектов инфраструктуры в радиусе поиска не найдено. + {locationDirectionSentence(data.indexLabel)}
- )} -
- Ориентировочная поправка по близости инфраструктуры - (OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована - на реальных ценовых сделках. -
-
- ) : ( -
- Данные о ближайшей инфраструктуре для этого адреса сейчас{" "} - недоступны — коэффициент - локации не корректирует текущую оценку. -
- )} +
+ Медиана ₽/м² рядом (радиус {data.radiusLabel}) + + {data.localMedianLabel} + +
+
+ Медиана ₽/м² по Екатеринбургу + + {data.cityMedianLabel} + +
+ {/* Honest, not illustrative: this metric никогда не идёт в цену + (аналоги уже берутся из этого же района — повторный учёт + локации был бы задвоением, см. app/services/location_index.py). */} +
+ Посчитано по {data.sampleSize}{" "} + {pluralRu(data.sampleSize, [ + "объявлению", + "объявлениям", + "объявлениям", + ])}{" "} + в радиусе {data.radiusLabel}. Справочно — на итоговую оценку не + влияет: аналоги для расчёта уже берутся из этого района. +
+ + + )} + {data.status === "out_of_coverage" && ( + <> + Локационный индекс считаем только по{" "} + Екатеринбургу — этот адрес + вне зоны покрытия, сравнение с городом недоступно. + + )} + {data.status === "insufficient_data" && ( + <> + Рядом нашлось только{" "} + {data.sampleSize}{" "} + сопоставимых объявлений (радиус {data.radiusLabel}) — этого + недостаточно для надёжного сравнения с городом. + + + )} + {data.status === "loading" && "Считаем локационный индекс…"} +
); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index a56dbdb3..7c66e2ba 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -13,7 +13,6 @@ import type { DealRow, DropdownOptions, History, - LocationFactors, MarketAds, MarketDeals, ObjectInfo, @@ -44,7 +43,8 @@ export const object: ObjectInfo = { houseType: "Панельный", repair: "Хороший", balcony: true, - locationCoef: "0.87", + locationIndexLabel: "+8%", + locationIndexOk: true, // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative // fixture coordinate for the HeroBar locator mini-map (unwired usage only). lat: 56.8384, @@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = { ], }; -// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- - -export const locationFactors: LocationFactors = { - coef: "0.87", - intro: - "Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.", - formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" }, - positives: [ - { label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" }, - { label: "Транспортные узлы и остановки рядом", delta: "+0.05" }, - { label: "Набережная и парк в 10 минутах", delta: "+0.04" }, - { label: "Развитая торговая инфраструктура", delta: "+0.03" }, - ], - negatives: [ - { label: "Оживлённая магистраль, шумовая нагрузка", delta: "−0.07" }, - { label: "Дефицит парковочных мест", delta: "−0.05" }, - { label: "Возраст жилого фонда района (1985)", delta: "−0.04" }, - { label: "Износ инженерных сетей квартала", delta: "−0.02" }, - ], - footer: - "Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.", -}; +// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient +// + base/coef/result formula + positives/negatives) lived here — removed +// together with the location-index rewrite. LocationDrawer now reads +// `LocationData` produced by `mapLocation` (./mappers.ts) from the real +// GET /trade-in/location-index response; its own default/unwired fixture is +// LOCATION_FIXTURE in ./LocationDrawer.tsx. // ---- NAV / CHROME --------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index eac886b6..f65f89d7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -18,8 +18,9 @@ // source groupBy) — out of scope for #2040/#2041, left as-is. // BE-2 target_address is a single string; street/city are split heuristically // (parseAddress). Backend should return structured address components. -// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here -// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317). +// BE-3 location index (replacing the broken location-coef, see +// backend/app/services/location_index.py) is wired here +// (mapObject/mapLocation consume GET /trade-in/location-index). // // Enum <-> RU reconciliation (design dropdowns have options with no enum value): // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) @@ -34,7 +35,7 @@ import type { HouseAnalyticsKpi, HouseAnalyticsResponse, HouseType, - LocationCoefResponse, + LocationIndexResponse, PlacementHistoryItem, RepairState, SalesVsListingsResponse, @@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report { } /** - * Location-coefficient delta label shared by mapObject (HeroBar tile) and - * mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05] - * (backend location_coef.py::_score_to_coef, NOT calibrated on real price - * deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the - * other honest deltas on this page), never a raw multiplier that would read as - * more precise than it is. "—" while absent/loading AND when - * geo_source="unavailable" (legitimate graceful fallback, not an error). + * Compact HeroBar badge for the location index — {label, ok} shared by + * mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for + * reuse. location_index_pct is a real % deviation of the local median ₽/м² + * from the citywide median (see backend/app/services/location_index.py) — a + * comparison metric, NOT a price multiplier, and it does NOT feed the + * estimate. `ok: true` only for status="ok" (a trustworthy percent); every + * other case gets a short, honest, DISTINCT reason instead of a blank dash — + * the drawer (mapLocation below) expands on each: + * - li == null → "нет данных" (not fetched yet / this estimate + * has no location index request enabled) + * - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg) + * - "insufficient_data" → "мало данных" (too few comparable listings) */ -function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string { - if (lc == null || lc.geo_source === "unavailable") return "—"; - return fmtPct((lc.coef - 1) * 100); +function locationIndexBadge( + li: LocationIndexResponse | null | undefined, +): { label: string; ok: boolean } { + if (li == null) return { label: "нет данных", ok: false }; + if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; + if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; + return { label: "мало данных", ok: false }; // status === "insufficient_data" } /** * Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). - * `coef` is the GET /trade-in/location-coef response (#2317) — optional/null - * while it is still loading or unavailable for this address. + * `locationIndex` is the GET /trade-in/location-index response — optional/null + * while it is still loading or not requested for this address. */ export function mapObject( e: AggregatedEstimate, - coef?: LocationCoefResponse | null, + locationIndex?: LocationIndexResponse | null, ): ObjectInfo { const { address, city } = parseAddress(e.target_address); + const badge = locationIndexBadge(locationIndex); return { address, city, @@ -817,7 +828,8 @@ export function mapObject( houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—", repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—", balcony: e.has_balcony ?? false, - locationCoef: coefDeltaLabel(coef), + locationIndexLabel: badge.label, + locationIndexOk: badge.ok, // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use — // null while the estimate has no geocode yet. lat: e.target_lat, @@ -825,10 +837,11 @@ export function mapObject( }; } -// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────── +// ── Location index (LocationDrawer «ЛОКАЦИЯ») ─────────────────────────────── // RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in -// backend/app/services/location_coef.py (top7 straight-line POI score). An -// unrecognised category (raw OSM tag outside that dict) falls back to a +// backend/app/services/location_index.py (top-N straight-line POI ranking — +// a qualitative "what's nearby" list only, no longer feeding any score/coef). +// An unrecognised category (raw OSM tag outside that dict) falls back to a // generic label rather than surfacing a raw enum-ish string to the user. const POI_CATEGORY_RU: Record = { metro_stop: "Метро", @@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string { return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK; } -/** One POI row in the LocationDrawer factor list. */ +/** One POI row in the LocationDrawer «что рядом» list. */ export interface LocationFactorRow { label: string; // POI name if known, else its RU category category: string; // RU category label (always present, for the badge) distance: string; // "150 м" / "1.2 км" } +/** + * LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own + * three-way honest-degradation contract ("loading" is an FE-only 4th state + * for li == null, e.g. query still pending) so the drawer can explain EACH + * case differently instead of collapsing them into one dash: + * - "ok" index/medians reliable, show the real numbers. + * - "out_of_coverage" address outside Yekaterinburg — the index simply + * doesn't cover it (not an error, not "no data"). + * - "insufficient_data" too few comparable listings even at the widest + * radius — sampleSize/radiusLabel still say what was actually found. + * - "loading" not fetched yet. + * indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the + * corresponding backend field is null (never a fabricated number). + */ export interface LocationData { - // true when the backend actually computed a coefficient for this address - // (geo_source="osm_poi_ekb"), even if no POI were found within radius - // (factors=[] is then a legitimate empty result, not "no data"). - available: boolean; - coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable - baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽" - resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽" + status: "ok" | "out_of_coverage" | "insufficient_data" | "loading"; + indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok" + localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null + cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null + sampleSize: number; // comparable active listings actually found (0 if n/a) + radiusLabel: string; // fmtDist(radius_m), "—" while loading + poiAvailable: boolean; // poi_status === "ok" (independent of status above) factors: LocationFactorRow[]; } /** - * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef - * (#2317). null/undefined/geo_source="unavailable" all degrade to an honest - * unavailable state — never a fabricated coefficient, price or factor list - * (mirrors the backend's own graceful-fallback contract). + * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index. + * null/undefined (not fetched yet) degrades to status="loading" — never a + * fabricated index, price or factor list (mirrors the backend's own + * graceful-fallback contract). */ export function mapLocation( - lc: LocationCoefResponse | null | undefined, + li: LocationIndexResponse | null | undefined, ): LocationData { - if (lc == null || lc.geo_source === "unavailable") { + if (li == null) { return { - available: false, - coefDelta: "—", - baseLabel: "—", - resultLabel: "—", + status: "loading", + indexLabel: "—", + localMedianLabel: "—", + cityMedianLabel: "—", + sampleSize: 0, + radiusLabel: "—", + poiAvailable: false, factors: [], }; } return { - available: true, - coefDelta: coefDeltaLabel(lc), - baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`, - resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`, - factors: lc.factors.map((f) => ({ - label: f.name?.trim() || poiCategoryLabel(f.poi_type), - category: poiCategoryLabel(f.poi_type), - distance: fmtDist(f.distance_m), + status: li.status, + indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—", + localMedianLabel: fmtPpm(li.local_median_price_per_m2), + cityMedianLabel: fmtPpm(li.city_median_price_per_m2), + sampleSize: li.sample_size, + radiusLabel: fmtDist(li.radius_m), + poiAvailable: li.poi_status === "ok", + factors: li.nearby_poi.map((p) => ({ + label: p.name?.trim() || poiCategoryLabel(p.poi_type), + category: poiCategoryLabel(p.poi_type), + distance: fmtDist(p.distance_m), })), }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 4db48392..3151b60c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -18,7 +18,15 @@ export interface ObjectInfo { houseType: string; repair: string; balcony: boolean; - locationCoef: string; + // Compact HeroBar badge for the location index (see mapObject / mappers.ts + // locationIndexBadge): "+12%"/"−8%" when the backend has a reliable value + // (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" / + // "нет данных" while loading) — never a fabricated percent. + locationIndexLabel: string; + // true only when locationIndexLabel is a real percent (status="ok") — tells + // HeroBar whether to render it as the accent numeric value or as the muted + // unavailable-reason pill. + locationIndexOk: boolean; // Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null // when the estimate has no geocode yet — the map then renders an honest // "нет координат" placeholder instead of an empty/broken box. @@ -287,21 +295,11 @@ export interface DropdownOptions { crm: string[]; } -// ---- КОЭФ. ЛОКАЦИИ DRAWER ------------------------------------------------- - -export interface LocationFactor { - label: string; - delta: string; -} - -export interface LocationFactors { - coef: string; - intro: string; - formula: { base: string; coef: string; result: string }; - positives: LocationFactor[]; - negatives: LocationFactor[]; - footer: string; -} +// Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/ +// result formula + positives/negatives) lived here — removed together with +// the location-index rewrite: LocationDrawer now consumes `LocationData` +// (see ./mappers.ts), which reflects the real backend contract, not the old +// fabricated formula. // ---- USER / CHROME -------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index fd00950f..b3982e11 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -12,7 +12,7 @@ import type { HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, - LocationCoefResponse, + LocationIndexResponse, PlacementHistoryItem, SalesVsListingsResponse, SellTimeSensitivityResponse, @@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) { } /** - * GET /api/v1/trade-in/location-coef?estimate_id=&radius_m= - * POI-based location coefficient for the estimate's target address (#2045 BE-3 - * backend, #2317 FE wiring — LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is - * an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas. - * geo_source="unavailable" is a legitimate graceful-fallback response (local POI - * mirror empty/stale on this environment, or the estimate has no lat/lon) — - * ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient. + * GET /api/v1/trade-in/location-index?estimate_id=&radius_m= + * Location index for the estimate's target address — replaces the broken + * location-coef (backend rewrite, see app/services/location_index.py): + * % deviation of the local median ₽/м² (comparable active listings near the + * address) from the citywide median ₽/м², NOT a price multiplier and NOT fed + * into the estimate. status="out_of_coverage"/"insufficient_data" are honest + * graceful-fallback responses (address outside Yekaterinburg / too few + * comparables) — ./v2/mappers.ts renders each distinctly, never a fabricated + * number. */ -export function useLocationCoef(estimate_id: string | null, radius_m?: number) { +export function useLocationIndex(estimate_id: string | null, radius_m?: number) { const params = new URLSearchParams(); if (estimate_id) params.set("estimate_id", estimate_id); if (radius_m != null) params.set("radius_m", String(radius_m)); - return useQuery({ - queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null], + return useQuery({ + queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null], queryFn: () => - apiFetch(`${BASE}/location-coef?${params}`), + apiFetch(`${BASE}/location-index?${params}`), enabled: estimate_id !== null && estimate_id.length > 0, staleTime: 10 * 60_000, }); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 8e559418..296882dc 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -470,27 +470,41 @@ export interface TradeInLeadResponse { status: string; // 'received' } -// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ── -// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ. -// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py -// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05]. -// result_price_rub = round(base_price_rub * coef). +// ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ── +// Replaces the broken location-coef (#2045 audit — see +// backend/app/services/location_index.py for the full history: the old +// `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real +// prices, and never actually fed the estimate). location_index_pct is the % +// deviation of the local median ₽/м² (comparable active listings near the +// address) from the citywide median ₽/м² — a real, uncalibrated-range +// comparison metric, NOT a price multiplier. It does NOT feed the estimate +// (analogs already carry location in the base price; folding this in again +// would double-count the same effect). // -// geo_source="unavailable" is a legitimate graceful-fallback response (the local -// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate -// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated. -// "osm_poi_ekb" is the normal/real-data source. -export interface LocationCoefFactor { +// status: +// "ok" — location_index_pct/local_median_price_per_m2 reliable. +// "out_of_coverage" — address outside the product's geo coverage +// (Yekaterinburg only). All numeric fields null — an honest dash, not 0%. +// "insufficient_data" — even at the widest search radius there are too few +// comparable active listings. Numeric fields null; sample_size/radius_m +// still report what was actually found. +// +// poi_status is independent of status above ("что рядом" and the numeric +// index degrade separately): "ok" | "unavailable" (local OSM POI mirror +// empty/not yet refreshed on this environment — never fabricated points). +export interface NearbyPoi { poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten" name: string | null; // POI name if known distance_m: number; - weight: number; // internal score contribution — NOT a per-POI price delta } -export interface LocationCoefResponse { - coef: number; - factors: LocationCoefFactor[]; - geo_source: string; // "osm_poi_ekb" | "unavailable" - base_price_rub: number; - result_price_rub: number; +export interface LocationIndexResponse { + status: "ok" | "out_of_coverage" | "insufficient_data"; + location_index_pct: number | null; + local_median_price_per_m2: number | null; + city_median_price_per_m2: number | null; + sample_size: number; + radius_m: number; + nearby_poi: NearbyPoi[]; + poi_status: "ok" | "unavailable"; }