fix(tradein/v2): подключить реальные данные location-coef в LocationDrawer + HeroBar (#2317) (#2341)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m37s
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 1m9s

This commit is contained in:
lekss361 2026-07-03 22:47:40 +00:00
parent e7f97f1355
commit 1d9e25ce07
6 changed files with 323 additions and 36 deletions

View file

@ -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() {
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
data={locationData}
/>
</div>
</div>

View file

@ -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. */}
нет данных
</span>
) : (
<span

View file

@ -1,26 +1,44 @@
"use client";
// "ПОЯСНЕНИЕ К РАСЧЁТУ" 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"
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
// footer — none of which the backend produces (location-coef is deferred,
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful
// documentation text only: how the estimate is built, and an explicit note that
// the location coefficient does not exist yet. 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 — 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.
import { useEffect, useRef } from "react";
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 {
open: boolean;
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
// 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,18 +301,19 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
сделки). CV коэффициент вариации выборки (разброс цен).
</div>
{/* location note section */}
{/* location section */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
color: tokens.body2,
marginTop: 26,
marginBottom: 12,
}}
>
ЛОКАЦИЯ
</div>
{data.available ? (
<div
style={{
fontSize: 12.5,
@ -306,10 +325,117 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
padding: "12px 14px",
}}
>
Коэффициент локации (инфраструктура, транспорт, шум) {" "}
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
локации не корректируется.
<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,
}}
>
{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>
</>
);

View file

@ -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<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),
})),
};
}

View file

@ -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<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
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset

View file

@ -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;
}