From 7080cce40e15c5bd0268c20f38ac546ad9448889 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:00:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(trade-in/ui):=20=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D0=BD=D1=8B=D0=B9=20analog=5Ftier,=20a?= =?UTF-8?q?ddress=5Fprecision=20=D0=B2=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8F=D1=85,=20expected=5Fsold=20=D0=B1=D0=B0=D1=80,=20ratio?= =?UTF-8?q?=5Fbasis=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/trade-in.ts: добавлен AnalogTier тип и analog_tier? поле в AggregatedEstimate (опциональное, graceful при absent/null) - HeroTransparency: resolveCompsTier() предпочитает analog_tier (структурный, без парсинга строки), fallback на compsTier() эвристику при absent/null; добавлена строка «Точность адреса» в collapsible «Как рассчитано» из address_precision с предупреждением при low+approximate - HeroSummary: третий бар «Ожидаемая цена сделки» (expected_sold_price_rub/low/high) с отличным цветом (viz-5/6) — graceful при null; ratio_basis показывается всегда: per_rooms→«по сделкам той же планировки», global_fallback→прежний текст --- .../src/components/trade-in/HeroSummary.tsx | 73 ++++++++++++++++++- .../components/trade-in/HeroTransparency.tsx | 72 +++++++++++++++++- tradein-mvp/frontend/src/types/trade-in.ts | 7 ++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index ece2a03c..cfe1d3ea 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -348,11 +348,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {soldPerM2.toLocaleString("ru-RU")} ₽/м² )} - {estimate.ratio_basis === "global_fallback" && ( + {estimate.ratio_basis === "global_fallback" ? (
- Ср.-городской коэффициент — мало сделок в сегменте + Ср.-городской коэффициент (мало сделок в сегменте)
- )} + ) : estimate.ratio_basis === "per_rooms" ? ( +
+ Коэффициент по сделкам той же планировки +
+ ) : null} )} @@ -523,6 +527,69 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals ); })()} + + {/* ── Задача 3: Бар «Ожидаемая цена сделки» (expected_sold) ── + Показывается только если поле присутствует и > 0. Graceful при null. + Визуально отличается: акцентный градиент (--viz-5/--viz-6) и пометка. */} + {hasSold && (() => { + // Строим бар либо из диапазона (low/high), либо из точечного значения ±0 span. + const esLo = + typeof soldLo === "number" && soldLo > 0 ? soldLo : (sold as number); + const esHi = + typeof soldHi === "number" && soldHi > 0 ? soldHi : (sold as number); + const esM = sold as number; + const esSpan = esHi - esLo; + const esMedPct = + esSpan > 0 + ? Math.max(5, Math.min(95, ((esM - esLo) / esSpan) * 100)) + : 50; + return ( +
+
+ + Ожидаемая цена сделки{" "} + (прогноз) + + медиана · {formatMln(esM)} ₽ +
+
+
+
+
+
+
+
{formatMln(esLo)}
+
+
+
+
{formatMln(esHi)}
+
+
+
+ {formatMln(esM)} ₽ +
+ {typeof soldPerM2 === "number" && ( +
+ {soldPerM2.toLocaleString("ru-RU")} ₽/м² +
+ )} + {showDiscount && ( +
+ −{discountPct}% к объявлению +
+ )} +
+
+ ); + })()}
)} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index fe54a325..62f4f8a9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -18,7 +18,7 @@ */ import { useState, useEffect } from "react"; -import type { AggregatedEstimate } from "@/types/trade-in"; +import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in"; import { API_BASE_URL } from "@/lib/api"; interface Props { @@ -35,6 +35,26 @@ const CONF_BAND: Record = { low: "низкая достоверность", }; +/** + * Структурный маппинг analog_tier → читаемый лейбл. + * Используется ПРЕДПОЧТИТЕЛЬНО если бэкенд уже отдаёт поле. + */ +const ANALOG_TIER_LABELS: Record = { + same_building: "по аналогам того же дома", + micro_radius: "по ближайшему окружению ≤500 м", + district: "по аналогам района", + city: "по аналогам города (широкий радиус)", +}; + +/** + * Маппинг address_precision → читаемый лейбл для коллапсибл-деталей. + */ +const PRECISION_LABELS: Record = { + house: "до дома", + street: "до улицы", + approximate: "приблизительно", +}; + /** * Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL. * БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled), @@ -43,9 +63,12 @@ const CONF_BAND: Record = { const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim(); /** - * Эвристика уровня сопоставимых объектов из confidence_explanation. + * LEGACY fallback: эвристика уровня сопоставимых объектов из confidence_explanation. * Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую. * Null → ничего не показываем (graceful). + * + * Используется только если бэкенд ещё не отдаёт структурного analog_tier — + * предпочитай resolveCompsTier() которая сначала смотрит на analog_tier. */ function compsTier(explanation: string | null): string | null { // FIXME: backend should expose structured comps_tier (#695 follow-up). @@ -64,6 +87,17 @@ function compsTier(explanation: string | null): string | null { return null; } +/** + * Предпочитает структурное поле analog_tier (если задано бэкендом). + * Если absent/null — graceful fallback на compsTier() эвристику. + */ +function resolveCompsTier(estimate: AggregatedEstimate): string | null { + if (estimate.analog_tier) { + return ANALOG_TIER_LABELS[estimate.analog_tier] ?? null; + } + return compsTier(estimate.confidence_explanation); +} + /** * «27 мая 2026, 14:30» — timeZone: "UTC" фиксирует вывод одинаково на SSR (Node UTC) * и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418. @@ -93,7 +127,16 @@ function freshnessFromScraped(estimate: AggregatedEstimate): Date | null { export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low; - const tier = compsTier(estimate.confidence_explanation); + // Задача 1: предпочитаем структурный analog_tier, fallback на эвристику строки. + const tier = resolveCompsTier(estimate); + // Задача 2: точность адреса в деталях (если задана). + const precisionLabel = + estimate.address_precision != null + ? (PRECISION_LABELS[estimate.address_precision] ?? null) + : null; + // Предупреждение при low-confidence + приблизительном адресе. + const showPrecisionWarning = + estimate.confidence === "low" && estimate.address_precision === "approximate"; // SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch). // Если его нет, fallback на data_freshness_minutes через Date.now() — @@ -190,6 +233,29 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { Достоверность оценки {band} + {precisionLabel && ( +
  • + Точность адреса + + {precisionLabel} + {showPrecisionWarning && ( + + ⚠️ + + )} + +
  • + )} {estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (

    {estimate.confidence_explanation}

    diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index b239c780..1093b32f 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -45,6 +45,10 @@ export type ConfidenceLevel = "low" | "medium" | "high"; // Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2. export type AddressPrecision = "house" | "street" | "approximate"; +// Структурный уровень аналогов (#695 follow-up) — бэкенд отдаёт начиная с deploy ветки +// feat/estimator-ui-transparency. До деплоя поле absent/null → graceful fallback на compsTier(). +export type AnalogTier = "same_building" | "micro_radius" | "district" | "city"; + export interface TradeInEstimateInput { address: string; // min 3, max 500 area_m2: number; // 10 < x < 500 @@ -167,6 +171,9 @@ export interface AggregatedEstimate { // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). // null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы. address_precision?: AddressPrecision | null; + // ── Структурный уровень аналогов (feat/estimator-ui-transparency) ── + // Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback. + analog_tier?: AnalogTier | null; // ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ── area_m2: number | null; rooms: number | null; -- 2.45.3