fix(tradein/ui): фронт под location-index — убрать обещание влияния на цену
Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат», из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала: estimator.py про location_coef не знает вовсе. Пара удалена. Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент» подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2 района и города. На итоговую оценку не влияет.» Три состояния недоступности теперь различимы вместо одного прочерка: «вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных» (сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние объяснено человеческим текстом, а при status=ok показано, по скольким объявлениям и в каком радиусе посчитано. Список «что рядом» сохранён как качественная справка, weight из ответа убран (был внутренней ранжирующей величиной, клиенту не значил ничего).
This commit is contained in:
parent
e767661503
commit
ec7fd9c02c
8 changed files with 388 additions and 299 deletions
|
|
@ -60,7 +60,7 @@ import {
|
|||
useEstimateMutation,
|
||||
useEstimatePlacementHistory,
|
||||
useEstimateSellTimeSensitivity,
|
||||
useLocationCoef,
|
||||
useLocationIndex,
|
||||
useSalesVsListings,
|
||||
useStreetDeals,
|
||||
} from "@/lib/trade-in-api";
|
||||
|
|
@ -134,7 +134,8 @@ const EMPTY_OBJECT: ObjectInfo = {
|
|||
houseType: "—",
|
||||
repair: "—",
|
||||
balcony: false,
|
||||
locationCoef: "—",
|
||||
locationIndexLabel: "—",
|
||||
locationIndexOk: false,
|
||||
lat: null,
|
||||
lon: null,
|
||||
};
|
||||
|
|
@ -471,7 +472,7 @@ export default function TradeInV2Page() {
|
|||
|
||||
// L3 — once the restore-by-id fetch has confirmed the estimate does not
|
||||
// exist (404), `currentEstimateId` below drops to null so the sibling
|
||||
// dashboard hooks (analytics/location-coef/placement-history/sell-time,
|
||||
// dashboard hooks (analytics/location-index/placement-history/sell-time,
|
||||
// all `enabled: estimate_id !== null`) don't each fire their own doomed
|
||||
// request against the same dead id. Hoisted above the sub-hooks (was
|
||||
// computed further down, after they'd already fired on the stale id).
|
||||
|
|
@ -532,10 +533,10 @@ export default function TradeInV2Page() {
|
|||
estimate?.rooms ?? null,
|
||||
);
|
||||
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
||||
// LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ» (#2317). Same independent-resolve
|
||||
// contract: a pending/errored/unavailable response degrades to the mapper's
|
||||
// honest "—" (mapObject/mapLocation), never a fabricated coefficient.
|
||||
const locationCoef = useLocationCoef(currentEstimateId);
|
||||
// LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
|
||||
// pending/errored/degraded response resolves to the mapper's own honest,
|
||||
// distinct reason (mapObject/mapLocation) — never a fabricated percent.
|
||||
const locationIndex = useLocationIndex(currentEstimateId);
|
||||
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
|
||||
// contract: each resolves independently; a pending/errored one degrades its
|
||||
// overlay section to an honest empty via the mapper (null input).
|
||||
|
|
@ -549,7 +550,7 @@ export default function TradeInV2Page() {
|
|||
|
||||
const streetDealsData = streetDeals.data ?? null;
|
||||
const analyticsData = analytics.data ?? null;
|
||||
const locationCoefData = locationCoef.data ?? null;
|
||||
const locationIndexData = locationIndex.data ?? null;
|
||||
const placementHistoryData = placementHistory.data ?? null;
|
||||
const salesVsListingsData = salesVsListings.data ?? null;
|
||||
const sellTimeData = sellTime.data ?? null;
|
||||
|
|
@ -575,12 +576,12 @@ export default function TradeInV2Page() {
|
|||
[estimate],
|
||||
);
|
||||
const objectInfo = useMemo(
|
||||
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT),
|
||||
[estimate, locationCoefData],
|
||||
() => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
|
||||
[estimate, locationIndexData],
|
||||
);
|
||||
const locationData = useMemo(
|
||||
() => mapLocation(locationCoefData),
|
||||
[locationCoefData],
|
||||
() => mapLocation(locationIndexData),
|
||||
[locationIndexData],
|
||||
);
|
||||
const resultPanelData = useMemo(
|
||||
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ interface HeroBarProps {
|
|||
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
||||
// desktop one — meta/buttons stack, the locator mini-map (and the
|
||||
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
||||
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
||||
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-index
|
||||
// drawer, so no functionality is lost, only the redundant map-card copy.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
|
@ -441,7 +441,7 @@ export default function HeroBar({
|
|||
the box is a fixed 560×152 with several absolutely-positioned
|
||||
children (address card) pinned to that size, so it cannot reflow to
|
||||
a phone width. «КАК РАССЧИТАНО» above still opens the same
|
||||
location-coef drawer, so no functionality is lost.
|
||||
location-index drawer, so no functionality is lost.
|
||||
User-reported bug: this used to be a single static building.png
|
||||
photo shown for EVERY estimate regardless of the real address (a
|
||||
user could be looking at someone else's building) — replaced with a
|
||||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
КОЭФ. ЛОКАЦИИ
|
||||
ЛОКАЦИЯ
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{data.object.locationCoef === "—" ? (
|
||||
{data.object.locationIndexOk ? (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.locationIndexLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
|
|
@ -571,22 +589,13 @@ export default function HeroBar({
|
|||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{/* #2317: coef is a live feature now (GET /location-coef) — a
|
||||
dash here means unavailable/loading for THIS estimate
|
||||
(unavailable geo_source, no lat/lon, or query pending),
|
||||
never "not built yet", so «скоро» would be stale/false. */}
|
||||
нет данных
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.locationCoef}
|
||||
{/* Distinct honest reasons instead of one blank dash — see
|
||||
mapObject/locationIndexBadge (./mappers.ts): "вне ЕКБ"
|
||||
(out_of_coverage — index only covers Yekaterinburg),
|
||||
"мало данных" (insufficient_data — too few comparable
|
||||
listings), "нет данных" (not fetched yet for this
|
||||
estimate). Full explanation lives in the drawer below. */}
|
||||
{data.object.locationIndexLabel}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,33 +1,128 @@
|
|||
"use client";
|
||||
|
||||
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to
|
||||
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||
// design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
|
||||
// a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
||||
// footer — none of which the backend produced at the time (location-coef was
|
||||
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef
|
||||
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI
|
||||
// factor list when available, and an HONEST "недоступно" state (never a fake
|
||||
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale
|
||||
// for this environment, or the estimate has no lat/lon). Keeps the same drawer
|
||||
// shell / slide animation / close button. Open/close is driven entirely by
|
||||
// props; while open it is a real modal dialog (role=dialog/aria-modal,
|
||||
// focus-trap, Esc) with semantics mirrored from SectionOverlay.
|
||||
// footer. That location-coef metric was replaced outright (see
|
||||
// backend/app/services/location_index.py for the full audit): ±5% range,
|
||||
// uncorrelated with real prices, never actually fed the estimate. This drawer
|
||||
// now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
|
||||
// % deviation of the local median ₽/м² from the citywide median — framed as a
|
||||
// comparison metric, explicitly NOT a price adjustment — plus the real
|
||||
// nearest-POI list. The three degraded states (loading / out_of_coverage /
|
||||
// insufficient_data) each get their own honest explanation instead of one
|
||||
// blank "недоступно". Keeps the same drawer shell / slide animation / close
|
||||
// button. Open/close is driven entirely by props; while open it is a real
|
||||
// modal dialog (role=dialog/aria-modal, focus-trap, Esc) with semantics
|
||||
// mirrored from SectionOverlay.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { LocationData } from "./mappers";
|
||||
|
||||
// Default presentation data (unwired usage / no coef fetched yet): the honest
|
||||
// unavailable state, never a fabricated coefficient.
|
||||
// Default presentation data (unwired usage / not fetched yet): the honest
|
||||
// loading state, never a fabricated coefficient.
|
||||
const LOCATION_FIXTURE: LocationData = {
|
||||
available: false,
|
||||
coefDelta: "—",
|
||||
baseLabel: "—",
|
||||
resultLabel: "—",
|
||||
status: "loading",
|
||||
indexLabel: "—",
|
||||
localMedianLabel: "—",
|
||||
cityMedianLabel: "—",
|
||||
sampleSize: 0,
|
||||
radiusLabel: "—",
|
||||
poiAvailable: false,
|
||||
factors: [],
|
||||
};
|
||||
|
||||
// data.indexLabel is already the signed, rounded fmtPct string produced by
|
||||
// mapLocation ("+12%" / "−8%" / "0%" / "—") — reusing it here (rather than a
|
||||
// second raw-number field) keeps the sign/rounding logic in one place
|
||||
// (./mappers.ts). Turns it into a plain-language comparison sentence instead
|
||||
// of a bare percent, so it reads as "vs. the city", never as a price change.
|
||||
function locationDirectionSentence(indexLabel: string): string {
|
||||
if (indexLabel.startsWith("−")) {
|
||||
return `Район дешевле города на ${indexLabel.slice(1)}`;
|
||||
}
|
||||
if (indexLabel.startsWith("+")) {
|
||||
return `Район дороже города на ${indexLabel.slice(1)}`;
|
||||
}
|
||||
if (indexLabel === "0%") return "Район на уровне медианы по городу";
|
||||
return "—";
|
||||
}
|
||||
|
||||
// «Что рядом» — qualitative POI list, independent of the numeric index
|
||||
// (poi_status degrades separately from status, see mapLocation/./mappers.ts).
|
||||
function PoiSection({ data }: { data: LocationData }) {
|
||||
if (!data.poiAvailable) {
|
||||
return (
|
||||
<div style={{ marginTop: 12, fontSize: 11.5, color: tokens.muted3 }}>
|
||||
Данные о ближайшей инфраструктуре сейчас недоступны.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
fontSize: 10,
|
||||
letterSpacing: "1px",
|
||||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
ЧТО РЯДОМ
|
||||
</div>
|
||||
{data.factors.length > 0 ? (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{data.factors.map((f, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
fontSize: 11.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.ink2 }}>
|
||||
{f.label}
|
||||
{f.category !== f.label && (
|
||||
<span style={{ color: tokens.muted3 }}> · {f.category}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
color: tokens.muted,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
{f.distance}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
|
||||
Объектов инфраструктуры в радиусе поиска не найдено.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocationDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -313,142 +408,95 @@ export function LocationDrawer({
|
|||
>
|
||||
ЛОКАЦИЯ
|
||||
</div>
|
||||
{data.available ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>Без поправки на локацию</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}>
|
||||
{data.baseLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 4,
|
||||
marginBottom: data.factors.length > 0 ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<span>С поправкой на локацию ({data.coefDelta})</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
{data.status === "ok" && (
|
||||
<>
|
||||
<div
|
||||
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
|
||||
>
|
||||
{data.resultLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Fix #7c (audit): the estimator never reads this coefficient — it's
|
||||
illustrative/reference only. Without this line the ₽-vs-₽ layout
|
||||
above reads as if it changes the quoted price. */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.5,
|
||||
color: tokens.muted3,
|
||||
}}
|
||||
>
|
||||
Справочно: поправка на локацию не влияет на итоговую оценку.
|
||||
</div>
|
||||
{data.factors.length > 0 ? (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{data.factors.map((f, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
fontSize: 11.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.ink2 }}>
|
||||
{f.label}
|
||||
{f.category !== f.label && (
|
||||
<span style={{ color: tokens.muted3 }}>
|
||||
{" "}
|
||||
· {f.category}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
color: tokens.muted,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
{f.distance}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
|
||||
Объектов инфраструктуры в радиусе поиска не найдено.
|
||||
{locationDirectionSentence(data.indexLabel)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.5,
|
||||
color: tokens.muted3,
|
||||
}}
|
||||
>
|
||||
Ориентировочная поправка по близости инфраструктуры
|
||||
(OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована
|
||||
на реальных ценовых сделках.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
Данные о ближайшей инфраструктуре для этого адреса сейчас{" "}
|
||||
<b style={{ color: tokens.ink2 }}>недоступны</b> — коэффициент
|
||||
локации не корректирует текущую оценку.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<span>Медиана ₽/м² рядом (радиус {data.radiusLabel})</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||
>
|
||||
{data.localMedianLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<span>Медиана ₽/м² по Екатеринбургу</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||
>
|
||||
{data.cityMedianLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Honest, not illustrative: this metric никогда не идёт в цену
|
||||
(аналоги уже берутся из этого же района — повторный учёт
|
||||
локации был бы задвоением, см. app/services/location_index.py). */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.5,
|
||||
color: tokens.muted3,
|
||||
}}
|
||||
>
|
||||
Посчитано по {data.sampleSize}{" "}
|
||||
{pluralRu(data.sampleSize, [
|
||||
"объявлению",
|
||||
"объявлениям",
|
||||
"объявлениям",
|
||||
])}{" "}
|
||||
в радиусе {data.radiusLabel}. Справочно — на итоговую оценку не
|
||||
влияет: аналоги для расчёта уже берутся из этого района.
|
||||
</div>
|
||||
<PoiSection data={data} />
|
||||
</>
|
||||
)}
|
||||
{data.status === "out_of_coverage" && (
|
||||
<>
|
||||
Локационный индекс считаем только по{" "}
|
||||
<b style={{ color: tokens.ink2 }}>Екатеринбургу</b> — этот адрес
|
||||
вне зоны покрытия, сравнение с городом недоступно.
|
||||
</>
|
||||
)}
|
||||
{data.status === "insufficient_data" && (
|
||||
<>
|
||||
Рядом нашлось только{" "}
|
||||
<b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
|
||||
сопоставимых объявлений (радиус {data.radiusLabel}) — этого
|
||||
недостаточно для надёжного сравнения с городом.
|
||||
<PoiSection data={data} />
|
||||
</>
|
||||
)}
|
||||
{data.status === "loading" && "Считаем локационный индекс…"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import type {
|
|||
DealRow,
|
||||
DropdownOptions,
|
||||
History,
|
||||
LocationFactors,
|
||||
MarketAds,
|
||||
MarketDeals,
|
||||
ObjectInfo,
|
||||
|
|
@ -44,7 +43,8 @@ export const object: ObjectInfo = {
|
|||
houseType: "Панельный",
|
||||
repair: "Хороший",
|
||||
balcony: true,
|
||||
locationCoef: "0.87",
|
||||
locationIndexLabel: "+8%",
|
||||
locationIndexOk: true,
|
||||
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
||||
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
||||
lat: 56.8384,
|
||||
|
|
@ -633,28 +633,12 @@ export const dropdownOptions: DropdownOptions = {
|
|||
],
|
||||
};
|
||||
|
||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
||||
|
||||
export const locationFactors: LocationFactors = {
|
||||
coef: "0.87",
|
||||
intro:
|
||||
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
||||
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
|
||||
positives: [
|
||||
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
|
||||
{ label: "Транспортные узлы и остановки рядом", delta: "+0.05" },
|
||||
{ label: "Набережная и парк в 10 минутах", delta: "+0.04" },
|
||||
{ label: "Развитая торговая инфраструктура", delta: "+0.03" },
|
||||
],
|
||||
negatives: [
|
||||
{ label: "Оживлённая магистраль, шумовая нагрузка", delta: "−0.07" },
|
||||
{ label: "Дефицит парковочных мест", delta: "−0.05" },
|
||||
{ label: "Возраст жилого фонда района (1985)", delta: "−0.04" },
|
||||
{ label: "Износ инженерных сетей квартала", delta: "−0.02" },
|
||||
],
|
||||
footer:
|
||||
"Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
|
||||
};
|
||||
// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient
|
||||
// + base/coef/result formula + positives/negatives) lived here — removed
|
||||
// together with the location-index rewrite. LocationDrawer now reads
|
||||
// `LocationData` produced by `mapLocation` (./mappers.ts) from the real
|
||||
// GET /trade-in/location-index response; its own default/unwired fixture is
|
||||
// LOCATION_FIXTURE in ./LocationDrawer.tsx.
|
||||
|
||||
// ---- NAV / CHROME ---------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
// source groupBy) — out of scope for #2040/#2041, left as-is.
|
||||
// BE-2 target_address is a single string; street/city are split heuristically
|
||||
// (parseAddress). Backend should return structured address components.
|
||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
||||
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
||||
// BE-3 location index (replacing the broken location-coef, see
|
||||
// backend/app/services/location_index.py) is wired here
|
||||
// (mapObject/mapLocation consume GET /trade-in/location-index).
|
||||
//
|
||||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||
|
|
@ -34,7 +35,7 @@ import type {
|
|||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
HouseType,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
PlacementHistoryItem,
|
||||
RepairState,
|
||||
SalesVsListingsResponse,
|
||||
|
|
@ -783,29 +784,39 @@ export function mapReport(e: AggregatedEstimate): Report {
|
|||
}
|
||||
|
||||
/**
|
||||
* Location-coefficient delta label shared by mapObject (HeroBar tile) and
|
||||
* mapLocation (LocationDrawer). coef is an MVP heuristic in [0.95, 1.05]
|
||||
* (backend location_coef.py::_score_to_coef, NOT calibrated on real price
|
||||
* deltas) — surfaced as a whole-percent delta via fmtPct (same rounding as the
|
||||
* other honest deltas on this page), never a raw multiplier that would read as
|
||||
* more precise than it is. "—" while absent/loading AND when
|
||||
* geo_source="unavailable" (legitimate graceful fallback, not an error).
|
||||
* Compact HeroBar badge for the location index — {label, ok} shared by
|
||||
* mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
|
||||
* reuse. location_index_pct is a real % deviation of the local median ₽/м²
|
||||
* from the citywide median (see backend/app/services/location_index.py) — a
|
||||
* comparison metric, NOT a price multiplier, and it does NOT feed the
|
||||
* estimate. `ok: true` only for status="ok" (a trustworthy percent); every
|
||||
* other case gets a short, honest, DISTINCT reason instead of a blank dash —
|
||||
* the drawer (mapLocation below) expands on each:
|
||||
* - li == null → "нет данных" (not fetched yet / this estimate
|
||||
* has no location index request enabled)
|
||||
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
|
||||
* - "insufficient_data" → "мало данных" (too few comparable listings)
|
||||
*/
|
||||
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string {
|
||||
if (lc == null || lc.geo_source === "unavailable") return "—";
|
||||
return fmtPct((lc.coef - 1) * 100);
|
||||
function locationIndexBadge(
|
||||
li: LocationIndexResponse | null | undefined,
|
||||
): { label: string; ok: boolean } {
|
||||
if (li == null) return { label: "нет данных", ok: false };
|
||||
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
|
||||
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
|
||||
return { label: "мало данных", ok: false }; // status === "insufficient_data"
|
||||
}
|
||||
|
||||
/**
|
||||
* Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
|
||||
* `coef` is the GET /trade-in/location-coef response (#2317) — optional/null
|
||||
* while it is still loading or unavailable for this address.
|
||||
* `locationIndex` is the GET /trade-in/location-index response — optional/null
|
||||
* while it is still loading or not requested for this address.
|
||||
*/
|
||||
export function mapObject(
|
||||
e: AggregatedEstimate,
|
||||
coef?: LocationCoefResponse | null,
|
||||
locationIndex?: LocationIndexResponse | null,
|
||||
): ObjectInfo {
|
||||
const { address, city } = parseAddress(e.target_address);
|
||||
const badge = locationIndexBadge(locationIndex);
|
||||
return {
|
||||
address,
|
||||
city,
|
||||
|
|
@ -817,7 +828,8 @@ export function mapObject(
|
|||
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
||||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||
balcony: e.has_balcony ?? false,
|
||||
locationCoef: coefDeltaLabel(coef),
|
||||
locationIndexLabel: badge.label,
|
||||
locationIndexOk: badge.ok,
|
||||
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
|
||||
// null while the estimate has no geocode yet.
|
||||
lat: e.target_lat,
|
||||
|
|
@ -825,10 +837,11 @@ export function mapObject(
|
|||
};
|
||||
}
|
||||
|
||||
// ── Location factors (LocationDrawer «ЛОКАЦИЯ») ─────────────────────────────
|
||||
// ── Location index (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────────
|
||||
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
|
||||
// backend/app/services/location_coef.py (top7 straight-line POI score). An
|
||||
// unrecognised category (raw OSM tag outside that dict) falls back to a
|
||||
// backend/app/services/location_index.py (top-N straight-line POI ranking —
|
||||
// a qualitative "what's nearby" list only, no longer feeding any score/coef).
|
||||
// An unrecognised category (raw OSM tag outside that dict) falls back to a
|
||||
// generic label rather than surfacing a raw enum-ish string to the user.
|
||||
const POI_CATEGORY_RU: Record<string, string> = {
|
||||
metro_stop: "Метро",
|
||||
|
|
@ -849,51 +862,71 @@ function poiCategoryLabel(poiType: string): string {
|
|||
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
|
||||
}
|
||||
|
||||
/** One POI row in the LocationDrawer factor list. */
|
||||
/** One POI row in the LocationDrawer «что рядом» list. */
|
||||
export interface LocationFactorRow {
|
||||
label: string; // POI name if known, else its RU category
|
||||
category: string; // RU category label (always present, for the badge)
|
||||
distance: string; // "150 м" / "1.2 км"
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own
|
||||
* three-way honest-degradation contract ("loading" is an FE-only 4th state
|
||||
* for li == null, e.g. query still pending) so the drawer can explain EACH
|
||||
* case differently instead of collapsing them into one dash:
|
||||
* - "ok" index/medians reliable, show the real numbers.
|
||||
* - "out_of_coverage" address outside Yekaterinburg — the index simply
|
||||
* doesn't cover it (not an error, not "no data").
|
||||
* - "insufficient_data" too few comparable listings even at the widest
|
||||
* radius — sampleSize/radiusLabel still say what was actually found.
|
||||
* - "loading" not fetched yet.
|
||||
* indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the
|
||||
* corresponding backend field is null (never a fabricated number).
|
||||
*/
|
||||
export interface LocationData {
|
||||
// true when the backend actually computed a coefficient for this address
|
||||
// (geo_source="osm_poi_ekb"), even if no POI were found within radius
|
||||
// (factors=[] is then a legitimate empty result, not "no data").
|
||||
available: boolean;
|
||||
coefDelta: string; // same formatting as ObjectInfo.locationCoef, "—" if unavailable
|
||||
baseLabel: string; // base_price_rub before the location adjustment, "X млн ₽"
|
||||
resultLabel: string; // result_price_rub = round(base_price_rub * coef), "X млн ₽"
|
||||
status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
|
||||
indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
|
||||
localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
|
||||
cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
|
||||
sampleSize: number; // comparable active listings actually found (0 if n/a)
|
||||
radiusLabel: string; // fmtDist(radius_m), "—" while loading
|
||||
poiAvailable: boolean; // poi_status === "ok" (independent of status above)
|
||||
factors: LocationFactorRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-coef
|
||||
* (#2317). null/undefined/geo_source="unavailable" all degrade to an honest
|
||||
* unavailable state — never a fabricated coefficient, price or factor list
|
||||
* (mirrors the backend's own graceful-fallback contract).
|
||||
* LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
|
||||
* null/undefined (not fetched yet) degrades to status="loading" — never a
|
||||
* fabricated index, price or factor list (mirrors the backend's own
|
||||
* graceful-fallback contract).
|
||||
*/
|
||||
export function mapLocation(
|
||||
lc: LocationCoefResponse | null | undefined,
|
||||
li: LocationIndexResponse | null | undefined,
|
||||
): LocationData {
|
||||
if (lc == null || lc.geo_source === "unavailable") {
|
||||
if (li == null) {
|
||||
return {
|
||||
available: false,
|
||||
coefDelta: "—",
|
||||
baseLabel: "—",
|
||||
resultLabel: "—",
|
||||
status: "loading",
|
||||
indexLabel: "—",
|
||||
localMedianLabel: "—",
|
||||
cityMedianLabel: "—",
|
||||
sampleSize: 0,
|
||||
radiusLabel: "—",
|
||||
poiAvailable: false,
|
||||
factors: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
coefDelta: coefDeltaLabel(lc),
|
||||
baseLabel: `${fmtMln(lc.base_price_rub)} млн ₽`,
|
||||
resultLabel: `${fmtMln(lc.result_price_rub)} млн ₽`,
|
||||
factors: lc.factors.map((f) => ({
|
||||
label: f.name?.trim() || poiCategoryLabel(f.poi_type),
|
||||
category: poiCategoryLabel(f.poi_type),
|
||||
distance: fmtDist(f.distance_m),
|
||||
status: li.status,
|
||||
indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
|
||||
localMedianLabel: fmtPpm(li.local_median_price_per_m2),
|
||||
cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
|
||||
sampleSize: li.sample_size,
|
||||
radiusLabel: fmtDist(li.radius_m),
|
||||
poiAvailable: li.poi_status === "ok",
|
||||
factors: li.nearby_poi.map((p) => ({
|
||||
label: p.name?.trim() || poiCategoryLabel(p.poi_type),
|
||||
category: poiCategoryLabel(p.poi_type),
|
||||
distance: fmtDist(p.distance_m),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,15 @@ export interface ObjectInfo {
|
|||
houseType: string;
|
||||
repair: string;
|
||||
balcony: boolean;
|
||||
locationCoef: string;
|
||||
// Compact HeroBar badge for the location index (see mapObject / mappers.ts
|
||||
// locationIndexBadge): "+12%"/"−8%" when the backend has a reliable value
|
||||
// (status="ok"), else a short honest reason ("вне ЕКБ" / "мало данных" /
|
||||
// "нет данных" while loading) — never a fabricated percent.
|
||||
locationIndexLabel: string;
|
||||
// true only when locationIndexLabel is a real percent (status="ok") — tells
|
||||
// HeroBar whether to render it as the accent numeric value or as the muted
|
||||
// unavailable-reason pill.
|
||||
locationIndexOk: boolean;
|
||||
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
|
||||
// when the estimate has no geocode yet — the map then renders an honest
|
||||
// "нет координат" placeholder instead of an empty/broken box.
|
||||
|
|
@ -287,21 +295,11 @@ export interface DropdownOptions {
|
|||
crm: string[];
|
||||
}
|
||||
|
||||
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
|
||||
|
||||
export interface LocationFactor {
|
||||
label: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface LocationFactors {
|
||||
coef: string;
|
||||
intro: string;
|
||||
formula: { base: string; coef: string; result: string };
|
||||
positives: LocationFactor[];
|
||||
negatives: LocationFactor[];
|
||||
footer: string;
|
||||
}
|
||||
// Note: the pre-wiring `LocationFactor`/`LocationFactors` types (base/coef/
|
||||
// result formula + positives/negatives) lived here — removed together with
|
||||
// the location-index rewrite: LocationDrawer now consumes `LocationData`
|
||||
// (see ./mappers.ts), which reflects the real backend contract, not the old
|
||||
// fabricated formula.
|
||||
|
||||
// ---- USER / CHROME --------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
LocationCoefResponse,
|
||||
LocationIndexResponse,
|
||||
PlacementHistoryItem,
|
||||
SalesVsListingsResponse,
|
||||
SellTimeSensitivityResponse,
|
||||
|
|
@ -147,22 +147,24 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
|
|||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/trade-in/location-coef?estimate_id=&radius_m=
|
||||
* POI-based location coefficient for the estimate's target address (#2045 BE-3
|
||||
* backend, #2317 FE wiring — LocationDrawer + HeroBar «КОЭФ. ЛОКАЦИИ»). coef is
|
||||
* an MVP heuristic in [0.95, 1.05], NOT calibrated on real price deltas.
|
||||
* geo_source="unavailable" is a legitimate graceful-fallback response (local POI
|
||||
* mirror empty/stale on this environment, or the estimate has no lat/lon) —
|
||||
* ./v2/mappers.ts renders it as an honest "—", never a fabricated coefficient.
|
||||
* GET /api/v1/trade-in/location-index?estimate_id=&radius_m=
|
||||
* Location index for the estimate's target address — replaces the broken
|
||||
* location-coef (backend rewrite, see app/services/location_index.py):
|
||||
* % deviation of the local median ₽/м² (comparable active listings near the
|
||||
* address) from the citywide median ₽/м², NOT a price multiplier and NOT fed
|
||||
* into the estimate. status="out_of_coverage"/"insufficient_data" are honest
|
||||
* graceful-fallback responses (address outside Yekaterinburg / too few
|
||||
* comparables) — ./v2/mappers.ts renders each distinctly, never a fabricated
|
||||
* number.
|
||||
*/
|
||||
export function useLocationCoef(estimate_id: string | null, radius_m?: number) {
|
||||
export function useLocationIndex(estimate_id: string | null, radius_m?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (estimate_id) params.set("estimate_id", estimate_id);
|
||||
if (radius_m != null) params.set("radius_m", String(radius_m));
|
||||
return useQuery<LocationCoefResponse>({
|
||||
queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null],
|
||||
return useQuery<LocationIndexResponse>({
|
||||
queryKey: ["trade-in", "location-index", estimate_id, radius_m ?? null],
|
||||
queryFn: () =>
|
||||
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`),
|
||||
apiFetch<LocationIndexResponse>(`${BASE}/location-index?${params}`),
|
||||
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -470,27 +470,41 @@ export interface TradeInLeadResponse {
|
|||
status: string; // 'received'
|
||||
}
|
||||
|
||||
// ── Location coefficient (endpoint: GET /trade-in/location-coef?estimate_id=&radius_m=) ──
|
||||
// #2045 BE-3 (backend) / #2317 (this FE wiring) — LocationDrawer + HeroBar «КОЭФ.
|
||||
// ЛОКАЦИИ». coef is an MVP heuristic (see backend app/services/location_coef.py
|
||||
// ::_score_to_coef) — NOT calibrated on real price deltas, range [0.95, 1.05].
|
||||
// result_price_rub = round(base_price_rub * coef).
|
||||
// ── Location index (endpoint: GET /trade-in/location-index?estimate_id=&radius_m=) ──
|
||||
// Replaces the broken location-coef (#2045 audit — see
|
||||
// backend/app/services/location_index.py for the full history: the old
|
||||
// `coef = 0.95 + score/100*0.10` was clamped to ±5%, uncorrelated with real
|
||||
// prices, and never actually fed the estimate). location_index_pct is the %
|
||||
// deviation of the local median ₽/м² (comparable active listings near the
|
||||
// address) from the citywide median ₽/м² — a real, uncalibrated-range
|
||||
// comparison metric, NOT a price multiplier. It does NOT feed the estimate
|
||||
// (analogs already carry location in the base price; folding this in again
|
||||
// would double-count the same effect).
|
||||
//
|
||||
// geo_source="unavailable" is a legitimate graceful-fallback response (the local
|
||||
// osm_poi_ekb_local mirror is empty/stale on this environment, OR the estimate
|
||||
// has no lat/lon) — coef=1.0 and factors=[] in that case, never fabricated.
|
||||
// "osm_poi_ekb" is the normal/real-data source.
|
||||
export interface LocationCoefFactor {
|
||||
// status:
|
||||
// "ok" — location_index_pct/local_median_price_per_m2 reliable.
|
||||
// "out_of_coverage" — address outside the product's geo coverage
|
||||
// (Yekaterinburg only). All numeric fields null — an honest dash, not 0%.
|
||||
// "insufficient_data" — even at the widest search radius there are too few
|
||||
// comparable active listings. Numeric fields null; sample_size/radius_m
|
||||
// still report what was actually found.
|
||||
//
|
||||
// poi_status is independent of status above ("что рядом" and the numeric
|
||||
// index degrade separately): "ok" | "unavailable" (local OSM POI mirror
|
||||
// empty/not yet refreshed on this environment — never fabricated points).
|
||||
export interface NearbyPoi {
|
||||
poi_type: string; // OSM POI category, e.g. "school" / "metro_stop" / "kindergarten"
|
||||
name: string | null; // POI name if known
|
||||
distance_m: number;
|
||||
weight: number; // internal score contribution — NOT a per-POI price delta
|
||||
}
|
||||
|
||||
export interface LocationCoefResponse {
|
||||
coef: number;
|
||||
factors: LocationCoefFactor[];
|
||||
geo_source: string; // "osm_poi_ekb" | "unavailable"
|
||||
base_price_rub: number;
|
||||
result_price_rub: number;
|
||||
export interface LocationIndexResponse {
|
||||
status: "ok" | "out_of_coverage" | "insufficient_data";
|
||||
location_index_pct: number | null;
|
||||
local_median_price_per_m2: number | null;
|
||||
city_median_price_per_m2: number | null;
|
||||
sample_size: number;
|
||||
radius_m: number;
|
||||
nearby_poi: NearbyPoi[];
|
||||
poi_status: "ok" | "unavailable";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue