From b815b2365566b2ad6038f9b675e0273d52ccd5b9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 23:49:54 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20source-registry=20+=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8/=D0=BB=D0=B5=D0=B9=D0=B1=D0=BB=D1=8B?= =?UTF-8?q?=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20(#2211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - новый src/lib/source-registry.ts — один источник правды (SOURCES as const, канон сверен с estimator._canonical_sources); n1 live:false, убран из ростера - HeroSummary: убран сфабрикованный «/7»; пустой sources_used → «источники не зафиксированы» вместо фейк-чипов; убрана выдуманная «классическая»; CV → честная метрика (реальный estimate.cv или лейбл «разброс») - ListingsCard: знаменатель убран совсем (sources_used включает оценочные источники — фиксированный знаменатель либо врёт, либо меньше числителя); fix невидимой rosreestr-точки (dot "ros"→"rosreestr", класса .ros нет в CSS) - SourcesProgress: ростер из registry, мёртвый N1 убран - EstimateForm: убран всегда-зелёный фейковый «● готов» cache-индикатор - page.tsx: восстановленный отчёт показывает created_at, не new Date() - types: +cv/source_counts/created_at (уже были в backend-схеме) - инвариант: type-level (as const satisfies) + контракт-комментарий против хардкод-массивов источников Refs #2211 --- tradein-mvp/frontend/src/app/page.tsx | 16 ++- .../src/components/trade-in/DealsCard.tsx | 21 +--- .../src/components/trade-in/EstimateForm.tsx | 4 +- .../src/components/trade-in/HeroSummary.tsx | 102 +++++++++++------- .../src/components/trade-in/ListingsCard.tsx | 49 ++++----- .../components/trade-in/SourcesProgress.tsx | 58 ++++------ .../frontend/src/lib/source-registry.ts | 84 +++++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 6 ++ 8 files changed, 221 insertions(+), 119 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/source-registry.ts diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index d5c343f0..db49b22d 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -36,6 +36,11 @@ import { TestPresets } from "@/components/trade-in/TestPresets"; import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api"; import { useQuota } from "@/lib/useQuota"; import { useBrand, useActiveBrandSlug } from "@/lib/useBrand"; +import { LIVE_LISTING_SOURCES } from "@/lib/source-registry"; + +// #2211 находка 7: список источников объявлений выводится из source-registry +// (n1 деактивирован #2204 → его тут больше нет), а не хардкодится «4 источника (…N1)». +const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.label).join(", "); function useEstimateId() { if (typeof window === "undefined") return null; @@ -182,7 +187,7 @@ export default function TradeInPage() {

Оценка квартиры на вторичке

- Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра. + Агрегируем источники объявлений ({LISTING_SOURCE_NAMES}) и реальные сделки Росреестра. Время сбора — 10–30 сек.

@@ -198,7 +203,14 @@ export default function TradeInPage() { Дата{" "} - {new Date().toLocaleDateString("ru-RU", { + {/* #2211 находка 6: дата создания оценки (created_at), а НЕ момент + открытия страницы. Для восстановленных из истории отчётов + new Date() показывал бы «сегодня» вместо реальной даты расчёта. + Fallback на now() только если оценки/поля ещё нет (пустой экран, + старый кеш без created_at). */} + {new Date( + resultData?.estimate.created_at ?? Date.now(), + ).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", diff --git a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx index d3b77708..9438f2e3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx @@ -5,23 +5,12 @@ */ import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; import { openRosreestrWithAddress } from "@/lib/rosreestr"; +import { sourceDot, sourceLabel } from "@/lib/source-registry"; interface Props { estimate: AggregatedEstimate; } -const SOURCE_DOTS: Record = { - rosreestr: "ros", - domklik: "dom", - etagi: "etagi", -}; - -const SOURCE_LABELS: Record = { - rosreestr: "Росреестр", - domklik: "ДомКлик", - etagi: "Этажи", -}; - function fmtRub(v: number): string { return v.toLocaleString("ru-RU"); } @@ -110,8 +99,8 @@ export function DealsCard({ estimate }: Props) {
{Object.entries(sourceCounts).map(([src, c]) => ( - {" "} - {SOURCE_LABELS[src] ?? src} ·{" "} + {" "} + {sourceLabel(src)} ·{" "} {c} сделок @@ -149,8 +138,8 @@ export function DealsCard({ estimate }: Props) { function DealRow({ deal }: { deal: AnalogLot }) { const src = deal.source ?? ""; - const dot = SOURCE_DOTS[src] ?? "dom"; - const label = SOURCE_LABELS[src] ?? src; + const dot = sourceDot(src); + const label = sourceLabel(src); // Tier badge для rosreestr deals (PR M / #564 Phase 3). // T0_per_house — точный кадастровый match (high confidence). diff --git a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx index 31d09d44..70fe120c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx @@ -520,9 +520,11 @@ export function EstimateForm({ )} + {/* #2211 находка 5: убран всегда-зелёный индикатор «● готов» — он не отражал + реального состояния кеша (в пропсах статуса нет). Оставлена только честная + политика TTL кеша по адресу. */}
Кэш по адресу — 24 ч - ● готов
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index f0fc7b62..5c3b365e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -10,6 +10,7 @@ import { useState } from "react"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; +import { sourceDot, sourceLabel } from "@/lib/source-registry"; import { HeroTransparency } from "./HeroTransparency"; interface Props { @@ -34,24 +35,6 @@ const REPAIR_LABELS: Record = { excellent: "Евроремонт", }; -const SOURCE_DOTS: Record = { - cian: "cian", - avito: "avito", - yandex: "yandex", - domklik: "dom", - rosreestr: "rosreestr", - n1: "n1", -}; - -const SOURCE_LABELS: Record = { - cian: "Циан", - avito: "Avito", - yandex: "Я.Недвижимость", - domklik: "ДомКлик", - rosreestr: "Росреестр", - n1: "N1.ru", -}; - const CONF_LABELS: Record = { high: { txt: "высокая", color: "var(--success)" }, medium: { txt: "средняя", color: "var(--warn)" }, @@ -78,11 +61,43 @@ function formatMln(rub: number): string { return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`; } -function calcCv(estimate: AggregatedEstimate): number { - // CV = (P75 - P25) / median * 100 (наш range_high - range_low = P25-P75 пара) +/** Русское склонение слова «источник» по количеству. */ +function pluralSources(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return "источник"; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "источника"; + return "источников"; +} + +/** + * Честная метрика разброса (#2211, находка 4). + * Прежний код называл «CV» величину (range_high − range_low) / median — это ширина + * межквартильного диапазона, а НЕ коэффициент вариации. Теперь: + * • если бэкенд отдал настоящий cv (std/mean ₽/м², #2043) — показываем его как «CV»; + * • иначе (старые оценки без cv) — показываем ширину диапазона под честным лейблом + * «разброс», без аббревиатуры CV. Новая метрика не выдумывается. + */ +function dispersionMetric( + estimate: AggregatedEstimate, +): { label: string; value: string; title: string } | null { + if (typeof estimate.cv === "number" && estimate.cv > 0) { + return { + label: "CV", + value: `${(estimate.cv * 100).toFixed(1)}%`, + title: "Коэффициент вариации цен аналогов (σ/среднее ₽/м²)", + }; + } const m = estimate.median_price_rub; - if (m === 0) return 0; - return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100; + if (m > 0 && estimate.range_high_rub > estimate.range_low_rub) { + const width = ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100; + return { + label: "разброс", + value: `${width.toFixed(1)}%`, + title: "Ширина ценового диапазона относительно медианы (не коэффициент вариации)", + }; + } + return null; } /** Returns true when address looks like a non-residential geocode fallback */ @@ -94,7 +109,7 @@ function isMockAddress(estimate: AggregatedEstimate): boolean { } export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { - const cv = calcCv(estimate); + const dispersion = dispersionMetric(estimate); // Defensive depth (audit #1871 P1.2): ghost-anchor guard. Если legacy-строка / // rehydrate / контракт-drift отдаст confidence='high' при n_analogs=0, UI должен // показывать «низкая», а не «высокая · 0 объектов». Бэкенд уже не отдаёт high при @@ -239,11 +254,19 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
Источник:{" "} - агрегация {estimate.sources_used.length}/7 + {estimate.sources_used.length > 0 + ? `агрегация ${estimate.sources_used.length} ${pluralSources(estimate.sources_used.length)}` + : "источники не зафиксированы"}
- Достоверность · {conf.txt} · CV {cv.toFixed(1)}% + Достоверность · {conf.txt} + {dispersion && ( + <> + {" · "} + {dispersion.label} {dispersion.value} + + )} {precisionBadge && ( {precisionBadge.text} @@ -267,9 +290,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals )}
{heroPhoto - ? `фото аналога${estimate.sources_used[0] ? ` · ${estimate.sources_used[0]}` : ""}` + ? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}` : estimate.sources_used.length > 0 - ? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов` + ? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов` : "Нет фото"}
@@ -316,7 +339,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
Планировка - {rooms === 0 ? "Студия" : `${rooms}-к`}, классическая + {rooms === 0 ? "Студия" : `${rooms}-к`}
{repairState && ( @@ -710,19 +733,22 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals )} - {/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */} + {/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── + #2211 находка 2: пустой sources_used → честный текст, а НЕ фейковые чипы + avito/cian/yandex/rosreestr, которых в оценке не было. Чипы строятся + только из фактического sources_used (лейблы/цвета — из source-registry). */}
Источники
- {(estimate.sources_used.length > 0 - ? estimate.sources_used - : ["avito", "cian", "yandex", "rosreestr"] - ).map((src) => ( - - {" "} - {SOURCE_LABELS[src] ?? src} - - ))} + {estimate.sources_used.length > 0 ? ( + estimate.sources_used.map((src) => ( + + {sourceLabel(src)} + + )) + ) : ( + источники не зафиксированы + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx index 49039236..b78b0f4a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx @@ -9,30 +9,13 @@ import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/type import { safeUrl } from "@/lib/safeUrl"; import { useEstimateCianPriceChanges } from "@/lib/trade-in-api"; import { openRosreestrWithAddress } from "@/lib/rosreestr"; +import { sourceDot, sourceLabel } from "@/lib/source-registry"; interface Props { estimate: AggregatedEstimate; estimateId?: string; } -const SOURCE_DOTS: Record = { - cian: "cian", - avito: "avito", - yandex: "yandex", - domklik: "dom", - rosreestr: "ros", - n1: "n1", -}; - -const SOURCE_LABELS: Record = { - cian: "Циан", - avito: "Avito", - yandex: "Я.Недв", - domklik: "ДомКлик", - rosreestr: "Росреестр", - n1: "N1.ru", -}; - function fmtRub(v: number): string { return v.toLocaleString("ru-RU"); } @@ -123,7 +106,10 @@ export function ListingsCard({ estimate, estimateId }: Props) { {humanizeAgo(estimate.data_freshness_minutes)}
- Источников: {estimate.sources_used.length} / 7 + {/* #2211: без знаменателя — sources_used включает и оценочные + источники, любой фиксированный знаменатель либо врёт, либо + оказывается меньше числителя (консистентно с HeroSummary). */} + Источников: {estimate.sources_used.length}
@@ -162,11 +148,22 @@ export function ListingsCard({ estimate, estimateId }: Props) {
млн + {/* #2211 находка 4: настоящий CV (σ/среднее ₽/м², #2043) когда бэкенд его + отдал; иначе честный «разброс» = ширина диапазона (НЕ коэффициент + вариации, поэтому без аббревиатуры CV). */}
- CV{" "} - {m > 0 - ? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%` - : "—"} + {typeof estimate.cv === "number" && estimate.cv > 0 ? ( + CV + ) : ( + + разброс + + )}{" "} + {typeof estimate.cv === "number" && estimate.cv > 0 + ? `${(estimate.cv * 100).toFixed(1)}%` + : m > 0 + ? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%` + : "—"}
@@ -198,7 +195,7 @@ export function ListingsCard({ estimate, estimateId }: Props) {
{Object.entries(sourceCounts).map(([src, count]) => ( - {SOURCE_LABELS[src] ?? src} ·{" "} + {sourceLabel(src)} ·{" "} {count} @@ -290,8 +287,8 @@ function AnalogRow({ priceChangeMap: Map; }) { const src = lot.source ?? ""; - const dot = SOURCE_DOTS[src] ?? "dom"; - const label = SOURCE_LABELS[src] ?? src; + const dot = sourceDot(src); + const label = sourceLabel(src); const distance = lot.distance_m === null ? "—" diff --git a/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx b/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx index 5728106b..d509804d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx @@ -3,12 +3,13 @@ /** * SourcesProgress — карточка «Шаг B · Агрегация». * - * Показывает 5 реальных источников: 4 источника объявлений (Циан, Авито, - * Яндекс, N1) + Росреестр (внутренние сделки). Статусы выводятся из реального - * `estimate.sources_used` / `countBySource` / `actual_deals`; источник без - * вклада честно показывает «нет данных». + * Ростер источников берётся из source-registry (живые листинговые + Росреестр), + * а НЕ из хардкода — #2211. n1 деактивирован (#2204) → в ростере его нет. + * Статусы выводятся из реального `estimate.sources_used` / `countBySource` / + * `actual_deals`; источник без вклада честно показывает «нет данных». */ import type { AggregatedEstimate } from "@/types/trade-in"; +import { LIVE_LISTING_SOURCES } from "@/lib/source-registry"; interface Props { estimate: AggregatedEstimate | null; @@ -38,42 +39,27 @@ export function SourcesProgress({ estimate, isPending }: Props) { } } + // Живые листинговые источники — из реестра (n1 исключён как деактивированный). + const listingRows: SourceRow[] = LIVE_LISTING_SOURCES.map((s) => ({ + key: s.id, + label: s.label, + dotClass: s.dot, + status: used.has(s.id) ? "done" : isPending ? "loading" : "idle", + count: countBySource[s.id], + })); + const rows: SourceRow[] = [ - { - key: "cian", - label: "Циан", - dotClass: "cian", - status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"), - count: countBySource.cian, - }, - { - key: "avito", - label: "Авито Оценка", - dotClass: "avito", - status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"), - count: countBySource.avito, - }, - { - key: "yandex", - label: "Я.Недвижимость", - dotClass: "yandex", - status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"), - count: countBySource.yandex, - }, - { - key: "n1", - label: "N1.ru", - dotClass: "n1", - status: used.has("n1") ? "done" : (isPending ? "loading" : "idle"), - count: countBySource.n1, - }, + ...listingRows, { key: "rosreestr", label: "Росреестр (внутр.)", - dotClass: "ros", - status: isDone && (estimate?.actual_deals.length ?? 0) > 0 - ? "done" - : (isPending ? "loading" : "idle"), + dotClass: "rosreestr", + status: + isDone && (estimate?.actual_deals.length ?? 0) > 0 + ? "done" + : isPending + ? "loading" + : "idle", count: estimate?.actual_deals.length, }, ]; diff --git a/tradein-mvp/frontend/src/lib/source-registry.ts b/tradein-mvp/frontend/src/lib/source-registry.ts new file mode 100644 index 00000000..4934a9a0 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/source-registry.ts @@ -0,0 +1,84 @@ +/** + * source-registry — единый источник правды по источникам данных «Меры» (trade-in). + * + * КОНТРАКТ ЧЕСТНОСТИ (#2211, класс #1968 — «долг честности отрастает без инвариантов»): + * 1. Здесь НЕТ и не должно появляться хардкод-массива «источников, которые якобы + * участвовали в оценке». Любой ростер/чип/лейбл/знаменатель в UI выводится ЛИБО + * из фактического `estimate.sources_used` (что реально дало данные), ЛИБО из + * `SOURCES` ниже (справочник известных источников для лейблов/цветов). + * 2. Знаменатели вида «N/7», «N/5» НЕ хардкодятся числом. Если нужен знаменатель + * «живых» источников — берётся из `LIVE_SOURCE_COUNT` (derived), а не из литерала. + * 3. Пустой `sources_used` → честный текст «источники не зафиксированы», НЕ подстановка + * фейковых чипов avito/cian/yandex/rosreestr. + * + * Канон набора — по backend `estimator.py::_canonical_sources`: + * sources_used = {листинговые источники, реально давшие аналоги} + * ∪ {'rosreestr' — сделки ДКП} + * ∪ {оценочные: 'avito_imv' | 'yandex_valuation' | 'cian_valuation'}. + * n1 деактивирован (#2204) → присутствует в справочнике для лейблинга исторических + * данных, но помечен live:false и исключён из «живого» ростера. + */ + +export type SourceKind = "listing" | "deals" | "valuation"; + +export interface SourceMeta { + /** Идентификатор как в `estimate.sources_used` / `analog.source`. */ + readonly id: string; + /** Краткий человекочитаемый лейбл для чипов. */ + readonly label: string; + /** CSS-класс цветной точки — `.src-dot.` в trade-in.css. */ + readonly dot: string; + /** Тип вклада: объявления / сделки / внешняя оценка. */ + readonly kind: SourceKind; + /** true — источник участвует в текущем сборе; false — деактивирован (n1). */ + readonly live: boolean; +} + +/** + * Справочник известных источников. `as const satisfies` фиксирует форму на уровне + * типов: массив readonly, добавить «фейковый» источник или сломать поле не даст TS. + * Cоответствие CSS-классам `.src-dot.*` (trade-in.css) проверено вручную — для + * rosreestr валиден класс `rosreestr` (не мифический `ros`, у которого нет стиля). + */ +export const SOURCES = [ + { id: "avito", label: "Avito", dot: "avito", kind: "listing", live: true }, + { id: "cian", label: "Циан", dot: "cian", kind: "listing", live: true }, + { id: "yandex", label: "Я.Недвижимость", dot: "yandex", kind: "listing", live: true }, + { id: "domklik", label: "ДомКлик", dot: "dom", kind: "listing", live: true }, + { id: "n1", label: "N1.ru", dot: "n1", kind: "listing", live: false }, + { id: "rosreestr", label: "Росреестр", dot: "rosreestr", kind: "deals", live: true }, + { id: "etagi", label: "Этажи", dot: "etagi", kind: "deals", live: true }, + { id: "avito_imv", label: "Avito Оценка", dot: "avito", kind: "valuation", live: true }, + { id: "yandex_valuation", label: "Я.Оценка", dot: "yandex", kind: "valuation", live: true }, + { id: "cian_valuation", label: "Циан Оценка", dot: "cian", kind: "valuation", live: true }, +] as const satisfies readonly SourceMeta[]; + +const BY_ID: ReadonlyMap = new Map(SOURCES.map((s) => [s.id, s])); + +/** Живые листинговые источники (объявления) — для ростера прогресса сбора. */ +export const LIVE_LISTING_SOURCES: readonly SourceMeta[] = SOURCES.filter( + (s) => s.kind === "listing" && s.live, +); + +/** + * Знаменатель «живых» источников: живые листинговые + сделки (rosreestr). + * Derived из реестра — заменяет хардкод «/7» / «/5» в компонентах. + */ +export const LIVE_SOURCE_COUNT: number = SOURCES.filter( + (s) => s.live && (s.kind === "listing" || s.kind === "deals"), +).length; + +/** Известен ли источник справочнику. */ +export function isKnownSource(id: string): boolean { + return BY_ID.has(id); +} + +/** Лейбл источника; неизвестный id возвращается как есть (честно, без выдумки). */ +export function sourceLabel(id: string): string { + return BY_ID.get(id)?.label ?? id; +} + +/** CSS-класс точки; неизвестный id → нейтральный `dom`. */ +export function sourceDot(id: string): string { + return BY_ID.get(id)?.dot ?? "dom"; +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index aeeb6d52..80d9c7af 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -171,6 +171,12 @@ export interface AggregatedEstimate { target_lat: number | null; target_lon: number | null; 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» data_freshness_minutes: number | null; // «обновлено N минут назад» last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) est_days_on_market: number | null; // прогноз срока продажи -- 2.45.3