fix(tradein/ui): фронт под location-index — убрать обещание влияния на цену

Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат»,
из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала:
estimator.py про location_coef не знает вовсе. Пара удалена.

Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент»
подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2
района и города. На итоговую оценку не влияет.»

Три состояния недоступности теперь различимы вместо одного прочерка:
«вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных»
(сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние
объяснено человеческим текстом, а при status=ok показано, по скольким
объявлениям и в каком радиусе посчитано.

Список «что рядом» сохранён как качественная справка, weight из ответа убран
(был внутренней ранжирующей величиной, клиенту не значил ничего).
This commit is contained in:
bot-backend 2026-07-26 23:32:03 +03:00
parent e767661503
commit ec7fd9c02c
8 changed files with 388 additions and 299 deletions

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
@ -535,7 +535,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",
@ -554,10 +561,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",
@ -571,22 +589,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,7 +408,6 @@ export function LocationDrawer({
> >
ЛОКАЦИЯ ЛОКАЦИЯ
</div> </div>
{data.available ? (
<div <div
style={{ style={{
fontSize: 12.5, fontSize: 12.5,
@ -325,16 +419,26 @@ export function LocationDrawer({
padding: "12px 14px", padding: "12px 14px",
}} }}
> >
{data.status === "ok" && (
<>
<div
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
>
{locationDirectionSentence(data.indexLabel)}
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginTop: 10,
}} }}
> >
<span>Без поправки на локацию</span> <span>Медиана /м² рядом (радиус {data.radiusLabel})</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}> <span
{data.baseLabel} style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
>
{data.localMedianLabel}
</span> </span>
</div> </div>
<div <div
@ -343,24 +447,18 @@ export function LocationDrawer({
alignItems: "baseline", alignItems: "baseline",
justifyContent: "space-between", justifyContent: "space-between",
marginTop: 4, marginTop: 4,
marginBottom: data.factors.length > 0 ? 10 : 0,
}} }}
> >
<span>С поправкой на локацию ({data.coefDelta})</span> <span>Медиана /м² по Екатеринбургу</span>
<span <span
style={{ style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
fontFamily: tokens.font.mono,
fontSize: 14,
fontWeight: 600,
color: tokens.accent,
}}
> >
{data.resultLabel} {data.cityMedianLabel}
</span> </span>
</div> </div>
{/* Fix #7c (audit): the estimator never reads this coefficient it's {/* Honest, not illustrative: this metric никогда не идёт в цену
illustrative/reference only. Without this line the -vs- layout (аналоги уже берутся из этого же района повторный учёт
above reads as if it changes the quoted price. */} локации был бы задвоением, см. app/services/location_index.py). */}
<div <div
style={{ style={{
marginTop: 8, marginTop: 8,
@ -369,86 +467,36 @@ export function LocationDrawer({
color: tokens.muted3, color: tokens.muted3,
}} }}
> >
Справочно: поправка на локацию не влияет на итоговую оценку. Посчитано по {data.sampleSize}{" "}
{pluralRu(data.sampleSize, [
"объявлению",
"объявлениям",
"объявлениям",
])}{" "}
в радиусе {data.radiusLabel}. Справочно на итоговую оценку не
влияет: аналоги для расчёта уже берутся из этого района.
</div> </div>
{data.factors.length > 0 ? ( <PoiSection data={data} />
<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> {data.status === "out_of_coverage" && (
<span <>
style={{ Локационный индекс считаем только по{" "}
flex: "0 0 auto", <b style={{ color: tokens.ink2 }}>Екатеринбургу</b> этот адрес
color: tokens.muted, вне зоны покрытия, сравнение с городом недоступно.
fontFamily: tokens.font.mono, </>
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div>
)} )}
<div {data.status === "insufficient_data" && (
style={{ <>
marginTop: 12, Рядом нашлось только{" "}
fontSize: 10.5, <b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
lineHeight: 1.5, сопоставимых объявлений (радиус {data.radiusLabel}) этого
color: tokens.muted3, недостаточно для надёжного сравнения с городом.
}} <PoiSection data={data} />
> </>
Ориентировочная поправка по близости инфраструктуры
(OpenStreetMap) MVP-эвристика, диапазон ±5%, не откалибрована
на реальных ценовых сделках.
</div>
</div>
) : (
<div
style={{
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 7,
padding: "12px 14px",
}}
>
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "}
<b style={{ color: tokens.ink2 }}>недоступны</b> коэффициент
локации не корректирует текущую оценку.
</div>
)} )}
{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";
} }