fix(tradein/ui): единый source-registry + честные знаменатели/лейблы источников (#2211) #2227
8 changed files with 221 additions and 119 deletions
|
|
@ -36,6 +36,11 @@ import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
import { useBrand, useActiveBrandSlug } from "@/lib/useBrand";
|
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() {
|
function useEstimateId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
@ -182,7 +187,7 @@ export default function TradeInPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1>Оценка квартиры на вторичке</h1>
|
<h1>Оценка квартиры на вторичке</h1>
|
||||||
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
||||||
Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра.
|
Агрегируем источники объявлений ({LISTING_SOURCE_NAMES}) и реальные сделки Росреестра.
|
||||||
Время сбора — <span className="num">10–30 сек</span>.
|
Время сбора — <span className="num">10–30 сек</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -198,7 +203,14 @@ export default function TradeInPage() {
|
||||||
<span className="dot muted" />
|
<span className="dot muted" />
|
||||||
<span>Дата</span>{" "}
|
<span>Дата</span>{" "}
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{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",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,12 @@
|
||||||
*/
|
*/
|
||||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||||||
|
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_DOTS: Record<string, string> = {
|
|
||||||
rosreestr: "ros",
|
|
||||||
domklik: "dom",
|
|
||||||
etagi: "etagi",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
rosreestr: "Росреестр",
|
|
||||||
domklik: "ДомКлик",
|
|
||||||
etagi: "Этажи",
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtRub(v: number): string {
|
function fmtRub(v: number): string {
|
||||||
return v.toLocaleString("ru-RU");
|
return v.toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +99,8 @@ export function DealsCard({ estimate }: Props) {
|
||||||
<div className="sources">
|
<div className="sources">
|
||||||
{Object.entries(sourceCounts).map(([src, c]) => (
|
{Object.entries(sourceCounts).map(([src, c]) => (
|
||||||
<span key={src} className="source-chip">
|
<span key={src} className="source-chip">
|
||||||
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
|
<span className={`src-dot ${sourceDot(src)}`} />{" "}
|
||||||
{SOURCE_LABELS[src] ?? src} ·{" "}
|
{sourceLabel(src)} ·{" "}
|
||||||
<span className="mono" style={{ color: "var(--muted)" }}>
|
<span className="mono" style={{ color: "var(--muted)" }}>
|
||||||
{c} сделок
|
{c} сделок
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -149,8 +138,8 @@ export function DealsCard({ estimate }: Props) {
|
||||||
|
|
||||||
function DealRow({ deal }: { deal: AnalogLot }) {
|
function DealRow({ deal }: { deal: AnalogLot }) {
|
||||||
const src = deal.source ?? "";
|
const src = deal.source ?? "";
|
||||||
const dot = SOURCE_DOTS[src] ?? "dom";
|
const dot = sourceDot(src);
|
||||||
const label = SOURCE_LABELS[src] ?? src;
|
const label = sourceLabel(src);
|
||||||
|
|
||||||
// Tier badge для rosreestr deals (PR M / #564 Phase 3).
|
// Tier badge для rosreestr deals (PR M / #564 Phase 3).
|
||||||
// T0_per_house — точный кадастровый match (high confidence).
|
// T0_per_house — точный кадастровый match (high confidence).
|
||||||
|
|
|
||||||
|
|
@ -520,9 +520,11 @@ export function EstimateForm({
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* #2211 находка 5: убран всегда-зелёный индикатор «● готов» — он не отражал
|
||||||
|
реального состояния кеша (в пропсах статуса нет). Оставлена только честная
|
||||||
|
политика TTL кеша по адресу. */}
|
||||||
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
|
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
|
||||||
<span>Кэш по адресу — <span className="num">24 ч</span></span>
|
<span>Кэш по адресу — <span className="num">24 ч</span></span>
|
||||||
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}>● готов</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useState } from "react";
|
||||||
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
|
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
|
||||||
import { asHouseType, asRepairState } from "@/types/trade-in";
|
import { asHouseType, asRepairState } from "@/types/trade-in";
|
||||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||||
|
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||||||
import { HeroTransparency } from "./HeroTransparency";
|
import { HeroTransparency } from "./HeroTransparency";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -34,24 +35,6 @@ const REPAIR_LABELS: Record<string, string> = {
|
||||||
excellent: "Евроремонт",
|
excellent: "Евроремонт",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_DOTS: Record<string, string> = {
|
|
||||||
cian: "cian",
|
|
||||||
avito: "avito",
|
|
||||||
yandex: "yandex",
|
|
||||||
domklik: "dom",
|
|
||||||
rosreestr: "rosreestr",
|
|
||||||
n1: "n1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
cian: "Циан",
|
|
||||||
avito: "Avito",
|
|
||||||
yandex: "Я.Недвижимость",
|
|
||||||
domklik: "ДомКлик",
|
|
||||||
rosreestr: "Росреестр",
|
|
||||||
n1: "N1.ru",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
|
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
|
||||||
high: { txt: "высокая", color: "var(--success)" },
|
high: { txt: "высокая", color: "var(--success)" },
|
||||||
medium: { txt: "средняя", color: "var(--warn)" },
|
medium: { txt: "средняя", color: "var(--warn)" },
|
||||||
|
|
@ -78,11 +61,43 @@ function formatMln(rub: number): string {
|
||||||
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
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;
|
const m = estimate.median_price_rub;
|
||||||
if (m === 0) return 0;
|
if (m > 0 && estimate.range_high_rub > estimate.range_low_rub) {
|
||||||
return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
|
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 */
|
/** 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) {
|
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-строка /
|
// Defensive depth (audit #1871 P1.2): ghost-anchor guard. Если legacy-строка /
|
||||||
// rehydrate / контракт-drift отдаст confidence='high' при n_analogs=0, UI должен
|
// rehydrate / контракт-drift отдаст confidence='high' при n_analogs=0, UI должен
|
||||||
// показывать «низкая», а не «высокая · 0 объектов». Бэкенд уже не отдаёт high при
|
// показывать «низкая», а не «высокая · 0 объектов». Бэкенд уже не отдаёт high при
|
||||||
|
|
@ -239,11 +254,19 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<div>
|
<div>
|
||||||
Источник:{" "}
|
Источник:{" "}
|
||||||
<b>
|
<b>
|
||||||
агрегация {estimate.sources_used.length}/7
|
{estimate.sources_used.length > 0
|
||||||
|
? `агрегация ${estimate.sources_used.length} ${pluralSources(estimate.sources_used.length)}`
|
||||||
|
: "источники не зафиксированы"}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · <abbr title="Коэффициент вариации (разброс цен аналогов)">CV</abbr> {cv.toFixed(1)}%
|
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b>
|
||||||
|
{dispersion && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<abbr title={dispersion.title}>{dispersion.label}</abbr> {dispersion.value}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{precisionBadge && (
|
{precisionBadge && (
|
||||||
<span className={precisionBadge.className} title="Точность определения адреса">
|
<span className={precisionBadge.className} title="Точность определения адреса">
|
||||||
{precisionBadge.text}
|
{precisionBadge.text}
|
||||||
|
|
@ -267,9 +290,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
)}
|
)}
|
||||||
<div className="photo-meta">
|
<div className="photo-meta">
|
||||||
{heroPhoto
|
{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.length > 0
|
||||||
? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов`
|
? `${sourceLabel(estimate.sources_used[0])} · ${estimate.n_analogs} аналогов`
|
||||||
: "Нет фото"}
|
: "Нет фото"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -316,7 +339,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<div className="meta-row">
|
<div className="meta-row">
|
||||||
<span className="k">Планировка</span>
|
<span className="k">Планировка</span>
|
||||||
<span className="v">
|
<span className="v">
|
||||||
{rooms === 0 ? "Студия" : `${rooms}-к`}, классическая
|
{rooms === 0 ? "Студия" : `${rooms}-к`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{repairState && (
|
{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). */}
|
||||||
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
|
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
|
||||||
<span className="lbl">Источники</span>
|
<span className="lbl">Источники</span>
|
||||||
<div className="sources">
|
<div className="sources">
|
||||||
{(estimate.sources_used.length > 0
|
{estimate.sources_used.length > 0 ? (
|
||||||
? estimate.sources_used
|
estimate.sources_used.map((src) => (
|
||||||
: ["avito", "cian", "yandex", "rosreestr"]
|
<span key={src} className="source-chip">
|
||||||
).map((src) => (
|
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)}
|
||||||
<span key={src} className="source-chip">
|
</span>
|
||||||
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
|
))
|
||||||
{SOURCE_LABELS[src] ?? src}
|
) : (
|
||||||
</span>
|
<span style={{ color: "var(--muted-2)" }}>источники не зафиксированы</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,30 +9,13 @@ import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/type
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
|
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
|
||||||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||||||
|
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
estimateId?: string;
|
estimateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_DOTS: Record<string, string> = {
|
|
||||||
cian: "cian",
|
|
||||||
avito: "avito",
|
|
||||||
yandex: "yandex",
|
|
||||||
domklik: "dom",
|
|
||||||
rosreestr: "ros",
|
|
||||||
n1: "n1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
cian: "Циан",
|
|
||||||
avito: "Avito",
|
|
||||||
yandex: "Я.Недв",
|
|
||||||
domklik: "ДомКлик",
|
|
||||||
rosreestr: "Росреестр",
|
|
||||||
n1: "N1.ru",
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtRub(v: number): string {
|
function fmtRub(v: number): string {
|
||||||
return v.toLocaleString("ru-RU");
|
return v.toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +106,10 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
||||||
<b className="mono">{humanizeAgo(estimate.data_freshness_minutes)}</b>
|
<b className="mono">{humanizeAgo(estimate.data_freshness_minutes)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
Источников: <b>{estimate.sources_used.length} / 7</b>
|
{/* #2211: без знаменателя — sources_used включает и оценочные
|
||||||
|
источники, любой фиксированный знаменатель либо врёт, либо
|
||||||
|
оказывается меньше числителя (консистентно с HeroSummary). */}
|
||||||
|
Источников: <b>{estimate.sources_used.length}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,11 +148,22 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
||||||
</span>
|
</span>
|
||||||
<span className="unit">млн</span>
|
<span className="unit">млн</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* #2211 находка 4: настоящий CV (σ/среднее ₽/м², #2043) когда бэкенд его
|
||||||
|
отдал; иначе честный «разброс» = ширина диапазона (НЕ коэффициент
|
||||||
|
вариации, поэтому без аббревиатуры CV). */}
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
CV{" "}
|
{typeof estimate.cv === "number" && estimate.cv > 0 ? (
|
||||||
{m > 0
|
<abbr title="Коэффициент вариации цен аналогов (σ/среднее ₽/м²)">CV</abbr>
|
||||||
? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%`
|
) : (
|
||||||
: "—"}
|
<abbr title="Ширина ценового диапазона относительно медианы (не коэффициент вариации)">
|
||||||
|
разброс
|
||||||
|
</abbr>
|
||||||
|
)}{" "}
|
||||||
|
{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)}%`
|
||||||
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -198,7 +195,7 @@ export function ListingsCard({ estimate, estimateId }: Props) {
|
||||||
<div className="sources">
|
<div className="sources">
|
||||||
{Object.entries(sourceCounts).map(([src, count]) => (
|
{Object.entries(sourceCounts).map(([src, count]) => (
|
||||||
<span key={src} className="source-chip">
|
<span key={src} className="source-chip">
|
||||||
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} /> {SOURCE_LABELS[src] ?? src} ·{" "}
|
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)} ·{" "}
|
||||||
<span className="mono" style={{ color: "var(--muted)" }}>
|
<span className="mono" style={{ color: "var(--muted)" }}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -290,8 +287,8 @@ function AnalogRow({
|
||||||
priceChangeMap: Map<string, CianPriceChangeStats>;
|
priceChangeMap: Map<string, CianPriceChangeStats>;
|
||||||
}) {
|
}) {
|
||||||
const src = lot.source ?? "";
|
const src = lot.source ?? "";
|
||||||
const dot = SOURCE_DOTS[src] ?? "dom";
|
const dot = sourceDot(src);
|
||||||
const label = SOURCE_LABELS[src] ?? src;
|
const label = sourceLabel(src);
|
||||||
const distance =
|
const distance =
|
||||||
lot.distance_m === null
|
lot.distance_m === null
|
||||||
? "—"
|
? "—"
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
/**
|
/**
|
||||||
* SourcesProgress — карточка «Шаг B · Агрегация».
|
* SourcesProgress — карточка «Шаг B · Агрегация».
|
||||||
*
|
*
|
||||||
* Показывает 5 реальных источников: 4 источника объявлений (Циан, Авито,
|
* Ростер источников берётся из source-registry (живые листинговые + Росреестр),
|
||||||
* Яндекс, N1) + Росреестр (внутренние сделки). Статусы выводятся из реального
|
* а НЕ из хардкода — #2211. n1 деактивирован (#2204) → в ростере его нет.
|
||||||
* `estimate.sources_used` / `countBySource` / `actual_deals`; источник без
|
* Статусы выводятся из реального `estimate.sources_used` / `countBySource` /
|
||||||
* вклада честно показывает «нет данных».
|
* `actual_deals`; источник без вклада честно показывает «нет данных».
|
||||||
*/
|
*/
|
||||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
import { LIVE_LISTING_SOURCES } from "@/lib/source-registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate | null;
|
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[] = [
|
const rows: SourceRow[] = [
|
||||||
{
|
...listingRows,
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "rosreestr",
|
key: "rosreestr",
|
||||||
label: "Росреестр (внутр.)",
|
label: "Росреестр (внутр.)",
|
||||||
dotClass: "ros",
|
dotClass: "rosreestr",
|
||||||
status: isDone && (estimate?.actual_deals.length ?? 0) > 0
|
status:
|
||||||
? "done"
|
isDone && (estimate?.actual_deals.length ?? 0) > 0
|
||||||
: (isPending ? "loading" : "idle"),
|
? "done"
|
||||||
|
: isPending
|
||||||
|
? "loading"
|
||||||
|
: "idle",
|
||||||
count: estimate?.actual_deals.length,
|
count: estimate?.actual_deals.length,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
84
tradein-mvp/frontend/src/lib/source-registry.ts
Normal file
84
tradein-mvp/frontend/src/lib/source-registry.ts
Normal file
|
|
@ -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.<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<string, SourceMeta> = 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";
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,12 @@ export interface AggregatedEstimate {
|
||||||
target_lat: number | null;
|
target_lat: number | null;
|
||||||
target_lon: number | null;
|
target_lon: number | null;
|
||||||
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<string, number>; // {'avito': 12, 'cian': 5}
|
||||||
|
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||||
est_days_on_market: number | null; // прогноз срока продажи
|
est_days_on_market: number | null; // прогноз срока продажи
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue