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 ab07840c..889cf35b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -64,7 +64,13 @@ function DeltaPill({ whiteSpace: "nowrap", }} > - + {delta} {deltaLabel} @@ -240,198 +246,250 @@ 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" + 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} +
+ ) : ( + <> +
+ + {card.value} + + + {card.unit} + +
+ {card.range && ( +
+ {card.range} +
+ )} + {card.ppm && ( +
+ {card.ppm} +
+ )} + {card.note && ( +
+ {card.note} +
+ )} + + )} + + {isEmptyCard ? ( + + ) : card.bars ? ( + <>
- {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} -
- ) : ( - <> -
+ {card.bars.map((h, bi) => ( - {card.value} - - - {card.unit} - -
- {card.range && ( -
- {card.range} -
- )} - {card.ppm && ( -
- {card.ppm} -
- )} - {card.note && ( -
- {card.note} -
- )} - - )} - - {isEmptyCard ? ( + /> + ))} +
- ) : card.bars ? ( - <> -
-
- {card.bars.map((h, bi) => ( - - ))} -
- -
- {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й +
+ {/* #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} -
- )} - + {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} +
)} - + + )} + ); })} @@ -610,8 +606,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 fb543494..72e45266 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -605,16 +605,10 @@ 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]; } @@ -642,9 +636,7 @@ 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)), @@ -833,13 +825,11 @@ 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" } @@ -1086,7 +1076,8 @@ export function marketPositionPill( 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, ["аналога", "аналогов", "аналогов"])}`; + const forms: [string, string, string] = ["аналога", "аналогов", "аналогов"]; + const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, forms)}`; return { delta, deltaLabel }; } @@ -1135,9 +1126,7 @@ 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) ?? {}), @@ -1153,9 +1142,7 @@ 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) @@ -2182,10 +2169,7 @@ 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 f7c3c002..9de256e9 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -10,7 +10,11 @@ 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"; @@ -29,19 +33,11 @@ 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"; @@ -107,9 +103,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) @@ -129,7 +125,7 @@ export interface AnalogLot { // month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району. export interface PriceTrendPoint { month: string; // "YYYY-MM" - ppm2: number; // ₽/м² + ppm2: number; // ₽/м² } export interface CianValuationSummary { @@ -137,7 +133,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. @@ -148,20 +144,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: оценки, @@ -183,7 +179,7 @@ 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; @@ -218,16 +214,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: населённый пункт/город/регион/не распознан). @@ -391,15 +387,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) ── @@ -408,7 +404,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; @@ -417,9 +413,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; @@ -434,9 +430,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; @@ -448,14 +444,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;