fix(tradein/v2): подключить реальные данные location-coef в LocationDrawer + HeroBar (#2317) #2341
6 changed files with 323 additions and 36 deletions
|
|
@ -38,6 +38,7 @@ import {
|
||||||
mapAnalytics,
|
mapAnalytics,
|
||||||
mapCache,
|
mapCache,
|
||||||
mapHistory,
|
mapHistory,
|
||||||
|
mapLocation,
|
||||||
mapMarkers,
|
mapMarkers,
|
||||||
mapObject,
|
mapObject,
|
||||||
mapReport,
|
mapReport,
|
||||||
|
|
@ -58,6 +59,7 @@ import {
|
||||||
useEstimateMutation,
|
useEstimateMutation,
|
||||||
useEstimatePlacementHistory,
|
useEstimatePlacementHistory,
|
||||||
useEstimateSellTimeSensitivity,
|
useEstimateSellTimeSensitivity,
|
||||||
|
useLocationCoef,
|
||||||
useSalesVsListings,
|
useSalesVsListings,
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
|
|
@ -452,6 +454,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
|
||||||
|
// contract: a pending/errored/unavailable response degrades to the mapper's
|
||||||
|
// honest "—" (mapObject/mapLocation), never a fabricated coefficient.
|
||||||
|
const locationCoef = useLocationCoef(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).
|
||||||
|
|
@ -465,6 +471,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 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;
|
||||||
|
|
@ -496,8 +503,12 @@ export default function TradeInV2Page() {
|
||||||
[estimate],
|
[estimate],
|
||||||
);
|
);
|
||||||
const objectInfo = useMemo(
|
const objectInfo = useMemo(
|
||||||
() => (estimate ? mapObject(estimate) : EMPTY_OBJECT),
|
() => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT),
|
||||||
[estimate],
|
[estimate, locationCoefData],
|
||||||
|
);
|
||||||
|
const locationData = useMemo(
|
||||||
|
() => mapLocation(locationCoefData),
|
||||||
|
[locationCoefData],
|
||||||
);
|
);
|
||||||
const resultPanelData = useMemo(
|
const resultPanelData = useMemo(
|
||||||
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||||||
|
|
@ -846,6 +857,7 @@ export default function TradeInV2Page() {
|
||||||
<LocationDrawer
|
<LocationDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
data={locationData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -438,7 +438,11 @@ export default function HeroBar({
|
||||||
padding: "1px 8px",
|
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>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,44 @@
|
||||||
"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 "КОЭФ. ЛОКАЦИИ"). This used to
|
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). It used to
|
||||||
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
// render 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 produces (location-coef is deferred,
|
// footer — none of which the backend produced at the time (location-coef was
|
||||||
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful
|
// deferred, backend #2045). #2317 wires the now-real GET /trade-in/location-coef
|
||||||
// documentation text only: how the estimate is built, and an explicit note that
|
// response (mapLocation, ./mappers.ts): a real coefficient + real nearest-POI
|
||||||
// the location coefficient does not exist yet. Keeps the same drawer shell /
|
// factor list when available, and an HONEST "недоступно" state (never a fake
|
||||||
// slide animation / close button. Open/close is driven entirely by props; while
|
// zero/coefficient) when geo_source="unavailable" (local POI mirror empty/stale
|
||||||
// open it is a real modal dialog (role=dialog/aria-modal, focus-trap, Esc) with
|
// for this environment, or the estimate has no lat/lon). Keeps the same drawer
|
||||||
// semantics mirrored from SectionOverlay.
|
// 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 type { LocationData } from "./mappers";
|
||||||
|
|
||||||
|
// Default presentation data (unwired usage / no coef fetched yet): the honest
|
||||||
|
// unavailable state, never a fabricated coefficient.
|
||||||
|
const LOCATION_FIXTURE: LocationData = {
|
||||||
|
available: false,
|
||||||
|
coefDelta: "—",
|
||||||
|
baseLabel: "—",
|
||||||
|
resultLabel: "—",
|
||||||
|
factors: [],
|
||||||
|
};
|
||||||
|
|
||||||
interface LocationDrawerProps {
|
interface LocationDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
data?: LocationData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
export function LocationDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
data = LOCATION_FIXTURE,
|
||||||
|
}: LocationDrawerProps) {
|
||||||
// Modal-dialog plumbing — mirrors SectionOverlay. `dialogRef` is the dialog
|
// Modal-dialog plumbing — mirrors SectionOverlay. `dialogRef` is the dialog
|
||||||
// root, `lastFocused` keeps the trigger so focus can be restored on close, and
|
// root, `lastFocused` keeps the trigger so focus can be restored on close, and
|
||||||
// `onCloseRef` holds the latest onClose so the effect (keyed only on `open`)
|
// `onCloseRef` holds the latest onClose so the effect (keyed only on `open`)
|
||||||
|
|
@ -283,33 +301,141 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
сделки). CV — коэффициент вариации выборки (разброс цен).
|
сделки). CV — коэффициент вариации выборки (разброс цен).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* location note section */}
|
{/* location section */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
letterSpacing: "2px",
|
letterSpacing: "2px",
|
||||||
color: tokens.muted2,
|
color: tokens.body2,
|
||||||
marginTop: 26,
|
marginTop: 26,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ЛОКАЦИЯ
|
ЛОКАЦИЯ
|
||||||
</div>
|
</div>
|
||||||
<div
|
{data.available ? (
|
||||||
style={{
|
<div
|
||||||
fontSize: 12.5,
|
style={{
|
||||||
lineHeight: 1.7,
|
fontSize: 12.5,
|
||||||
color: tokens.body2,
|
lineHeight: 1.7,
|
||||||
background: tokens.infoSoftBg,
|
color: tokens.body2,
|
||||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
background: tokens.infoSoftBg,
|
||||||
borderRadius: 7,
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
padding: "12px 14px",
|
borderRadius: 7,
|
||||||
}}
|
padding: "12px 14px",
|
||||||
>
|
}}
|
||||||
Коэффициент локации (инфраструктура, транспорт, шум) —{" "}
|
>
|
||||||
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
<div
|
||||||
локации не корректируется.
|
style={{
|
||||||
</div>
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.resultLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.factors.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: "none",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.factors.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 11.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: tokens.ink2 }}>
|
||||||
|
{f.label}
|
||||||
|
{f.category !== f.label && (
|
||||||
|
<span style={{ color: tokens.muted3 }}>
|
||||||
|
{" "}
|
||||||
|
· {f.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: tokens.muted,
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.distance}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
|
||||||
|
Объектов инфраструктуры в радиусе поиска не найдено.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
// of scope for #2040/#2041, left as-is.
|
// 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 / street-view caption / compass bearing are not in
|
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
||||||
// the API → surfaced as placeholders.
|
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
||||||
|
// street-view caption / compass bearing are still not in the API →
|
||||||
|
// remain placeholders.
|
||||||
//
|
//
|
||||||
// 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)
|
||||||
|
|
@ -33,6 +35,7 @@ import type {
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseType,
|
HouseType,
|
||||||
|
LocationCoefResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
RepairState,
|
RepairState,
|
||||||
SalesVsListingsResponse,
|
SalesVsListingsResponse,
|
||||||
|
|
@ -730,8 +733,29 @@ export function mapReport(e: AggregatedEstimate): Report {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). */
|
/**
|
||||||
export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
* 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).
|
||||||
|
*/
|
||||||
|
function coefDeltaLabel(lc: LocationCoefResponse | null | undefined): string {
|
||||||
|
if (lc == null || lc.geo_source === "unavailable") return "—";
|
||||||
|
return fmtPct((lc.coef - 1) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function mapObject(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
coef?: LocationCoefResponse | null,
|
||||||
|
): ObjectInfo {
|
||||||
const { address, city } = parseAddress(e.target_address);
|
const { address, city } = parseAddress(e.target_address);
|
||||||
return {
|
return {
|
||||||
address,
|
address,
|
||||||
|
|
@ -744,9 +768,82 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||||||
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: "—", // TODO BE-3
|
locationCoef: coefDeltaLabel(coef),
|
||||||
streetView: "", // TODO BE-3
|
streetView: "", // TODO BE-3 (backend does not surface a street-view caption)
|
||||||
compass: "", // TODO BE-3
|
compass: "", // TODO BE-3 (backend does not surface a compass bearing)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Location factors (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
|
||||||
|
// generic label rather than surfacing a raw enum-ish string to the user.
|
||||||
|
const POI_CATEGORY_RU: Record<string, string> = {
|
||||||
|
metro_stop: "Метро",
|
||||||
|
school: "Школа",
|
||||||
|
kindergarten: "Детский сад",
|
||||||
|
hospital: "Больница",
|
||||||
|
shop_mall: "ТРЦ",
|
||||||
|
shop_supermarket: "Супермаркет",
|
||||||
|
bus_stop: "Остановка",
|
||||||
|
park: "Парк",
|
||||||
|
pharmacy: "Аптека",
|
||||||
|
tram_stop: "Трамвайная остановка",
|
||||||
|
shop_small: "Магазин у дома",
|
||||||
|
};
|
||||||
|
const POI_CATEGORY_FALLBACK = "Объект инфраструктуры";
|
||||||
|
|
||||||
|
function poiCategoryLabel(poiType: string): string {
|
||||||
|
return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One POI row in the LocationDrawer factor 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 км"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 млн ₽"
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
export function mapLocation(
|
||||||
|
lc: LocationCoefResponse | null | undefined,
|
||||||
|
): LocationData {
|
||||||
|
if (lc == null || lc.geo_source === "unavailable") {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
coefDelta: "—",
|
||||||
|
baseLabel: "—",
|
||||||
|
resultLabel: "—",
|
||||||
|
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),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
|
LocationCoefResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
SalesVsListingsResponse,
|
SalesVsListingsResponse,
|
||||||
SellTimeSensitivityResponse,
|
SellTimeSensitivityResponse,
|
||||||
|
|
@ -126,6 +127,28 @@ 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.
|
||||||
|
*/
|
||||||
|
export function useLocationCoef(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],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<LocationCoefResponse>(`${BASE}/location-coef?${params}`),
|
||||||
|
enabled: estimate_id !== null && estimate_id.length > 0,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/trade-in/street-deals
|
* GET /api/v1/trade-in/street-deals
|
||||||
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset
|
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset
|
||||||
|
|
|
||||||
|
|
@ -442,3 +442,28 @@ export interface GeocodeSuggestion {
|
||||||
export interface GeocodeSuggestResponse {
|
export interface GeocodeSuggestResponse {
|
||||||
items: GeocodeSuggestion[];
|
items: GeocodeSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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).
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue