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; // прогноз срока продажи