From 03af7386e9cc27257f4d9713332e8bd59b50213a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 17:00:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein/v2):=20#2899=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D0=B0/=D0=BC=D0=B0=D0=BF=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0/ResultPanel=20(=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82:=20git=20checkout=20HEAD=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=82=D0=B8=D0=BB=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=87=D0=B5=D0=B5=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/trade-in/v2/ResultPanel.tsx | 617 +++++++++--------- .../src/components/trade-in/v2/mappers.ts | 57 +- tradein-mvp/frontend/src/types/trade-in.ts | 90 +-- 3 files changed, 418 insertions(+), 346 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index 80895489..ab07840c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -37,6 +37,41 @@ const { font, } = tokens; +// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка +// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX +// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы +// молча. +function DeltaPill({ + delta, + deltaLabel, +}: { + delta: string; + deltaLabel?: string; +}) { + return ( + + + {delta} + + {deltaLabel} + + ); +} + interface ResultPanelProps { // Required on the app path (v2/page.tsx always supplies mapResultPanel // output) — an omitted prop must be a TS error, not a silent fallback to @@ -205,319 +240,198 @@ export default function ResultPanel({ const isHeadline = !isEmptyCard && ci === headlineIndex; const isAsking = ci === 0; return ( -
-
+
- {lines(card.title)} -
- {/* #1991 — explicit "this is the figure that goes into the offer" +
+ {lines(card.title)} +
+ {/* #1991 — explicit "this is the figure that goes into the offer" mark on the headline card, so the two dominant prices are not just visually bigger but literally labelled which one to quote. */} - {isHeadline && !isEmptyCard && ( - - В ОФФЕР - - )} -
- {isRegistry && ( -
- {/* §M8 — расшифровка «ДКП» один раз на секцию. */} - СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ -
- )} - {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an - honest note pointing at the recommended asking price. */} - {isEmptyCard ? ( -
- {card.emptyNote} -
- ) : ( - <> -
+ {isHeadline && !isEmptyCard && ( - {card.value} - - - {card.unit} + В ОФФЕР -
- {card.range && ( -
- {card.range} -
)} - {card.ppm && ( -
- {card.ppm} -
- )} - {card.note && ( -
- {card.note} -
- )} - - )} - - {isEmptyCard ? ( - - ) : card.bars ? ( -
+
+ {isRegistry && (
- {card.bars.map((h, bi) => ( - - ))} + {/* §M8 — расшифровка «ДКП» один раз на секцию. */} + СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
- -
- ) : ( - // M2 — calm delta pill (was a 51px circular gauge that read like a - // tech "занижение" indicator). Full text «−18% к цене объявления», - // neutral accentDeep on a soft-blue chip, tabular-nums. - <> -
- {card.delta ? ( - + ) : ( + <> +
- {card.delta} + {card.value} - - {card.deltaLabel} + + {card.unit} - - ) : ( - - )} +
+ {card.range && ( +
+ {card.range} +
+ )} + {card.ppm && ( +
+ {card.ppm} +
+ )} + {card.note && ( +
+ {card.note} +
+ )} + + )} + + {isEmptyCard ? ( -
- {card.exposureNote && ( -
- {card.exposureNote} -
+ ) : card.bars ? ( + <> +
+
+ {card.bars.map((h, bi) => ( + + ))} +
+ +
+ {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й + перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */} + {card.delta ? ( +
+ +
+ ) : null} + + ) : ( + // M2 — calm delta pill (was a 51px circular gauge that read like a + // tech "занижение" indicator). Full text «−18% к цене объявления», + // neutral accentDeep on a soft-blue chip, tabular-nums. + <> +
+ {card.delta ? ( + + ) : ( + + )} + +
+ {card.exposureNote && ( +
+ {card.exposureNote} +
+ )} + )} - - )} -
+
); })} @@ -583,8 +610,8 @@ export default function ResultPanel({ marginTop: -6, }} > - {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, - а не погрешность оценки. + {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а + не погрешность оценки. {/* ranges + radar */} 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 777918d1..fb543494 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -605,10 +605,16 @@ interface DealTier { } /** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ -function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { +function newestLot( + lots: AnalogLot[], +): [string | null, "day" | "quarter" | null] { let best: AnalogLot | null = null; for (const l of lots) { - if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; + if ( + l.listing_date && + (!best?.listing_date || l.listing_date > best.listing_date) + ) + best = l; } return [best?.listing_date ?? null, best?.date_precision ?? null]; } @@ -636,7 +642,9 @@ function resolveDealTier( // Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the // same way the ads card does — otherwise a single mis-scraped lot bins // over [min,max] and crushes the real deals into the left buckets. - bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), + bars: bins8( + guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean, + ), // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, // так что максимум по ним = максимум по всей выборке, не по показанным. asOf: dealsAsOfLabel(...newestLot(sd.deals)), @@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report { * - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg) * - "insufficient_data" → "мало данных" (too few comparable listings) */ -function locationIndexBadge( - li: LocationIndexResponse | null | undefined, -): { label: string; ok: boolean } { +function locationIndexBadge(li: LocationIndexResponse | null | undefined): { + label: string; + ok: boolean; +} { if (li == null) return { label: "нет данных", ok: false }; - if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; + if (li.status === "ok") + return { label: fmtPct(li.location_index_pct), ok: true }; if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; return { label: "мало данных", ok: false }; // status === "insufficient_data" } @@ -1063,6 +1073,23 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] { } /** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */ +// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке +// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов +// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию). +// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»; +// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ +// location_index_pct (район против города) — тот живёт в HeroBar. +export function marketPositionPill( + e: Pick, +): { delta: string; deltaLabel: string } | undefined { + const pct = e.market_percentile; + if (pct == null || !Number.isFinite(pct)) return undefined; + const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке"; + const n = e.n_analogs; + const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`; + return { delta, deltaLabel }; +} + export function mapResultPanel( e: AggregatedEstimate, streetDeals?: StreetDealsResponse | null, @@ -1108,7 +1135,12 @@ export function mapResultPanel( // 872k выброс this card already reports as "исключён", squashing the real // analogs into the left bins while the number says the outlier is dropped. // dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn). - bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), + bars: bins8( + guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean, + ), + // #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная + // плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null). + ...(marketPositionPill(e) ?? {}), nav: 2, }, { @@ -1121,7 +1153,9 @@ export function mapResultPanel( e.expected_sold_range_high_rub, ) : "", - ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", + ppm: hasExpected + ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` + : "", delta: hasExpected && e.asking_to_sold_ratio != null ? fmtPct((e.asking_to_sold_ratio - 1) * 100) @@ -2148,7 +2182,10 @@ export function mapSources( // sample still shown in the table right below this KPI tile) — a bare // "0" here would directly contradict visible rows. Fall back to the // actual displayed population (same fix as ListingsCard's count-strip). - count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—", + count: + e != null + ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) + : "—", median: e != null ? fmtMln(e.median_price_rub) : "—", ppm: e != null && Number.isFinite(e.median_price_per_m2) diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index c136e129..f7c3c002 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -10,11 +10,7 @@ export interface QuotaStatus { } export type HouseType = - | "panel" - | "brick" - | "monolith" - | "monolith_brick" - | "other"; + "panel" | "brick" | "monolith" | "monolith_brick" | "other"; export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; @@ -33,11 +29,19 @@ export const REPAIR_STATES: readonly RepairState[] = [ "good", "excellent", ]; -export function asHouseType(v: string | null | undefined): HouseType | undefined { - return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined; +export function asHouseType( + v: string | null | undefined, +): HouseType | undefined { + return v && (HOUSE_TYPES as readonly string[]).includes(v) + ? (v as HouseType) + : undefined; } -export function asRepairState(v: string | null | undefined): RepairState | undefined { - return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined; +export function asRepairState( + v: string | null | undefined, +): RepairState | undefined { + return v && (REPAIR_STATES as readonly string[]).includes(v) + ? (v as RepairState) + : undefined; } export type ConfidenceLevel = "low" | "medium" | "high"; @@ -103,9 +107,9 @@ export interface AnalogLot { days_on_market: number | null; photo_url: string | null; // ── Слой 5.2 — clickable links + distance ── - source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' - source_url: string | null; // ссылка на оригинал - distance_m: number | null; // расстояние до целевой квартиры в метрах + source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr' + source_url: string | null; // ссылка на оригинал + distance_m: number | null; // расстояние до целевой квартиры в метрах // ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ── // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T1_per_street' — street-level only (default for all rosreestr deals) @@ -125,7 +129,7 @@ export interface AnalogLot { // month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району. export interface PriceTrendPoint { month: string; // "YYYY-MM" - ppm2: number; // ₽/м² + ppm2: number; // ₽/м² } export interface CianValuationSummary { @@ -133,7 +137,7 @@ export interface CianValuationSummary { rent_price_rub: number | null; chart: Array<{ date: string; price: number }>; chart_change_pct: number | null; - chart_change_direction: 'increase' | 'decrease' | 'neutral' | null; + chart_change_direction: "increase" | "decrease" | "neutral" | null; } // Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback. @@ -144,20 +148,20 @@ export type RatioBasis = "per_rooms" | "global_fallback"; // blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи. export interface AvitoImvSummary { recommended_price: number | null; // рекомендованная цена Avito, ₽ - lower_price: number | null; // нижняя граница IMV-коридора, ₽ - higher_price: number | null; // верхняя граница IMV-коридора, ₽ - market_count: number | null; // объём рынка, на котором построена оценка + lower_price: number | null; // нижняя граница IMV-коридора, ₽ + higher_price: number | null; // верхняя граница IMV-коридора, ₽ + market_count: number | null; // объём рынка, на котором построена оценка } // ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ── // Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице // нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект). export interface DkpCorridor { - count: number; // число ДКП-сделок в выборке - low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) - median_ppm2: number; // медиана ₽/м² - high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) - period_months: number; // окно ПОИСКА сделок — НЕ возраст данных + count: number; // число ДКП-сделок в выборке + low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) + median_ppm2: number; // медиана ₽/м² + high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) + period_months: number; // окно ПОИСКА сделок — НЕ возраст данных // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, @@ -179,10 +183,14 @@ export interface AggregatedEstimate { expected_sold_range_high_rub?: number | null; expected_sold_per_m2?: number | null; asking_to_sold_ratio?: number | null; // sold / asking (≈0.82) - ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' + ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback' confidence: ConfidenceLevel; confidence_explanation: string | null; n_analogs: number; + // #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля + // аналогов дешевле». null/отсутствует = когорта < 15 либо старая оценка; + // показывать только вместе с n_analogs. НЕ location_index_pct (тот про район). + market_percentile?: number | null; insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных) // fix (never-block estimate) — оценка теперь показывается всегда, пока цена // посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по @@ -210,16 +218,16 @@ export interface AggregatedEstimate { // Ephemeral (не персистится в БД, только для текущего ответа) — optional, // т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат. target_city_ambiguous?: boolean; - sources_used: string[]; // ['avito', 'cian', 'rosreestr'] + sources_used: string[]; // ['avito', 'cian', 'rosreestr'] // #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean), // счётчики аналогов по источнику, момент создания оценки. Все optional: старые // кешированные оценки их не содержат → UI graceful fallback. - cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен - source_counts?: Record; // {'avito': 12, 'cian': 5} - created_at?: string | null; // ISO datetime — «отчёт от DD.MM» + cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен + source_counts?: Record; // {'avito': 12, 'cian': 5} + created_at?: string | null; // ISO datetime — «отчёт от DD.MM» data_freshness_minutes: number | null; // «обновлено N минут назад» - last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) - est_days_on_market: number | null; // прогноз срока продажи + last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) + est_days_on_market: number | null; // прогноз срока продажи // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). @@ -383,15 +391,15 @@ export interface HouseAnalyticsResponse { // ── Street deals (endpoint: GET /trade-in/street-deals) ── export interface StreetDealsResponse { - street: string | null; // "Космонавтов" / null если не извлёкся - period_from: string; // ISO date "2025-05-29" + street: string | null; // "Космонавтов" / null если не извлёкся + period_from: string; // ISO date "2025-05-29" period_to: string; - count: number; // all matching, не топ-10 + count: number; // all matching, не топ-10 median_price_rub: number; median_price_per_m2: number; range_low_rub: number; range_high_rub: number; - deals: AnalogLot[]; // top-10 по deal_date DESC + deals: AnalogLot[]; // top-10 по deal_date DESC } // ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ── @@ -400,7 +408,7 @@ export interface StreetDealsResponse { export interface SalesListingPair { deal_id: number; - deal_date: string; // ISO date + deal_date: string; // ISO date deal_price_rub: number; deal_price_per_m2: number; deal_area_m2: number; @@ -409,9 +417,9 @@ export interface SalesListingPair { deal_address: string; listing_id: number | null; - listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) + listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники) listing_source_url: string | null; - listing_date: string | null; // ISO date + listing_date: string | null; // ISO date listing_price_rub: number | null; listing_price_per_m2: number | null; listing_area_m2: number | null; @@ -426,9 +434,9 @@ export interface SalesListingPair { export interface SalesVsListingsResponse { street: string | null; - period_months: number; // 24 default - window_days: number; // 180 default - area_tolerance: number; // 0.15 default + period_months: number; // 24 default + window_days: number; // 180 default + area_tolerance: number; // 0.15 default total_deals: number; deals_with_listings: number; linkage_rate_pct: number; @@ -440,14 +448,14 @@ export interface SalesVsListingsResponse { // Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть // сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок. data_quality: "house_linked" | "street_only" | "no_data"; - pairs: SalesListingPair[]; // все sorted by deal_date DESC + pairs: SalesListingPair[]; // все sorted by deal_date DESC } // ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ── export interface SellTimeBucket { price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10' - price_premium_pct: number; // -5, 0, 5, 10 + price_premium_pct: number; // -5, 0, 5, 10 median_exposure_days: number | null; p25_days: number | null; p75_days: number | null;