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
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:
parent
e7f97f1355
commit
1d9e25ce07
6 changed files with 323 additions and 36 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,33 +301,141 @@ 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>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
Коэффициент локации (инфраструктура, транспорт, шум) —{" "}
|
||||
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
||||
локации не корректируется.
|
||||
</div>
|
||||
{data.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,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue