diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 36e1d1ff..4d5ca0df 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -38,6 +38,7 @@ import { mapAnalytics, mapCache, mapHistory, + mapLocation, mapMarkers, mapObject, mapReport, @@ -58,6 +59,7 @@ import { useEstimateMutation, useEstimatePlacementHistory, useEstimateSellTimeSensitivity, + useLocationCoef, useSalesVsListings, useStreetDeals, } from "@/lib/trade-in-api"; @@ -452,6 +454,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); // 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). @@ -465,6 +471,7 @@ export default function TradeInV2Page() { const streetDealsData = streetDeals.data ?? null; const analyticsData = analytics.data ?? null; + const locationCoefData = locationCoef.data ?? null; const placementHistoryData = placementHistory.data ?? null; const salesVsListingsData = salesVsListings.data ?? null; const sellTimeData = sellTime.data ?? null; @@ -496,8 +503,12 @@ export default function TradeInV2Page() { [estimate], ); const objectInfo = useMemo( - () => (estimate ? mapObject(estimate) : EMPTY_OBJECT), - [estimate], + () => (estimate ? mapObject(estimate, locationCoefData) : EMPTY_OBJECT), + [estimate, locationCoefData], + ); + const locationData = useMemo( + () => mapLocation(locationCoefData), + [locationCoefData], ); const resultPanelData = useMemo( () => (estimate ? mapResultPanel(estimate, streetDealsData) : null), @@ -846,6 +857,7 @@ export default function TradeInV2Page() { setDrawerOpen(false)} + data={locationData} /> diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index 45d2e4e5..51c13c44 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -438,7 +438,11 @@ 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. */} + нет данных ) : ( 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 // 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`) @@ -283,33 +301,141 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { сделки). CV — коэффициент вариации выборки (разброс цен). - {/* location note section */} + {/* location section */}
ЛОКАЦИЯ
-
- Коэффициент локации (инфраструктура, транспорт, шум) —{" "} - в разработке; текущая оценка по - локации не корректируется. -
+ {data.available ? ( +
+
+ Без поправки на локацию + + {data.baseLabel} + +
+
0 ? 10 : 0, + }} + > + С поправкой на локацию ({data.coefDelta}) + + {data.resultLabel} + +
+ {data.factors.length > 0 ? ( +
    + {data.factors.map((f, i) => ( +
  • + + {f.label} + {f.category !== f.label && ( + + {" "} + · {f.category} + + )} + + + {f.distance} + +
  • + ))} +
+ ) : ( +
+ Объектов инфраструктуры в радиусе поиска не найдено. +
+ )} +
+ Ориентировочная поправка по близости инфраструктуры + (OpenStreetMap) — MVP-эвристика, диапазон ±5%, не откалибрована + на реальных ценовых сделках. +
+
+ ) : ( +
+ Данные о ближайшей инфраструктуре для этого адреса сейчас{" "} + недоступны — коэффициент + локации не корректирует текущую оценку. +
+ )} ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index fd21f7b1..3025a402 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -17,8 +17,10 @@ // 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 / street-view caption / compass bearing are not in -// the API → surfaced as placeholders. +// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here +// (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): // house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type) @@ -33,6 +35,7 @@ import type { HouseAnalyticsKpi, HouseAnalyticsResponse, HouseType, + LocationCoefResponse, PlacementHistoryItem, RepairState, 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); return { address, @@ -744,9 +768,82 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo { 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: "—", // TODO BE-3 - streetView: "", // TODO BE-3 - compass: "", // TODO BE-3 + locationCoef: coefDeltaLabel(coef), + streetView: "", // TODO BE-3 (backend does not surface a street-view caption) + 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 = { + 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), + })), }; } diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 010bb9f1..a14435a4 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -12,6 +12,7 @@ import type { HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, + LocationCoefResponse, PlacementHistoryItem, SalesVsListingsResponse, 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({ + queryKey: ["trade-in", "location-coef", estimate_id, radius_m ?? null], + queryFn: () => + apiFetch(`${BASE}/location-coef?${params}`), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} + /** * GET /api/v1/trade-in/street-deals * ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 09327203..86bb7eec 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -442,3 +442,28 @@ export interface GeocodeSuggestion { export interface GeocodeSuggestResponse { 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; +}