feat(tradein/v2): #2899 — сами правки типа/маппера/ResultPanel (в первый коммит попал только тест: git checkout HEAD откатил рабочее дерево до коммита)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 56s

This commit is contained in:
bot-backend 2026-08-21 17:00:05 +05:00
parent 07655a2642
commit 03af7386e9
3 changed files with 418 additions and 346 deletions

View file

@ -37,6 +37,41 @@ const {
font,
} = tokens;
// #2899 — одна и та же спокойная плашка для «18% · к цене объявления» (карточка
// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX
// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы
// молча.
function DeltaPill({
delta,
deltaLabel,
}: {
delta: string;
deltaLabel?: string;
}) {
return (
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 5,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<span style={{ fontFamily: font.mono, fontSize: 12, fontWeight: 600 }}>
{delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
</span>
);
}
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 (
<div
key={ci}
style={{
position: "relative",
background: isHeadline
? infoSoftBg
: isRegistry
? surface.w40
: surface.w55,
backdropFilter: "blur(6px)",
border: isHeadline
? `1.5px solid ${accent}`
: isRegistry
? `1px solid ${lineSoft}`
: `1px solid ${line2}`,
borderRadius: 8,
padding: "15px 16px 13px",
boxShadow: isHeadline
? "0 6px 22px rgba(46,139,255,.16)"
: undefined,
}}
>
<div
key={ci}
style={{
position: "absolute",
left: -1,
top: -1,
width: 12,
height: 12,
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
}}
/>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
position: "relative",
background: isHeadline
? infoSoftBg
: isRegistry
? surface.w40
: surface.w55,
backdropFilter: "blur(6px)",
border: isHeadline
? `1.5px solid ${accent}`
: isRegistry
? `1px solid ${lineSoft}`
: `1px solid ${line2}`,
borderRadius: 8,
padding: "15px 16px 13px",
boxShadow: isHeadline
? "0 6px 22px rgba(46,139,255,.16)"
: undefined,
}}
>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
// even the AA "large text" floor (3:1). accentDeep is the same
// brand hue, darkened (existing token, not a one-off hex) — it
// clears ~4.2:1 here, so the label reads reliably too.
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6,
position: "absolute",
left: -1,
top: -1,
width: 12,
height: 12,
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
}}
/>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
{lines(card.title)}
</div>
{/* #1991 explicit "this is the figure that goes into the offer"
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
// even the AA "large text" floor (3:1). accentDeep is the same
// brand hue, darkened (existing token, not a one-off hex) — it
// clears ~4.2:1 here, so the label reads reliably too.
color: isHeadline
? accentDeep
: isRegistry
? muted2
: body2,
fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6,
}}
>
{lines(card.title)}
</div>
{/* #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 && (
<span
style={{
flex: "0 0 auto",
fontSize: 8,
fontWeight: 700,
letterSpacing: 1,
color: onAccent,
background: accentDeep,
borderRadius: 3,
padding: "3px 6px",
whiteSpace: "nowrap",
}}
>
В ОФФЕР
</span>
)}
</div>
{isRegistry && (
<div
style={{
fontSize: 8,
letterSpacing: 1.2,
color: muted3,
marginTop: 3,
}}
>
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
</div>
)}
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» just an
honest note pointing at the recommended asking price. */}
{isEmptyCard ? (
<div
style={{
marginTop: 12,
fontSize: 11,
lineHeight: 1.5,
color: body,
maxWidth: "94%",
}}
>
{card.emptyNote}
</div>
) : (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
{isHeadline && !isEmptyCard && (
<span
style={{
fontFamily: font.mono,
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
// yet clearly below the accent headline (40).
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1,
// Contrast follow-up (audit #2266) — accentDeep, not
// accent, for the same reason as the title label above.
color: isHeadline
? accentDeep
: isRegistry
? muted
: isAsking
? ink
: undefined,
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
flex: "0 0 auto",
fontSize: 8,
fontWeight: 700,
letterSpacing: 1,
color: onAccent,
background: accentDeep,
borderRadius: 3,
padding: "3px 6px",
whiteSpace: "nowrap",
}}
>
{card.unit}
В ОФФЕР
</span>
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
)}
{card.ppm && (
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
)}
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.note}
</div>
)}
</>
)}
{isEmptyCard ? (
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
width: "100%",
background: "none",
border: "none",
padding: 0,
margin: 0,
marginTop: 16,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
) : card.bars ? (
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
}}
>
</div>
{isRegistry && (
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 2,
height: 20,
flex: 1,
fontSize: 8,
letterSpacing: 1.2,
color: muted3,
marginTop: 3,
}}
>
{card.bars.map((h, bi) => (
<span
key={bi}
style={{
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
/>
))}
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
</div>
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
)}
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» just an
honest note pointing at the recommended asking price. */}
{isEmptyCard ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
marginTop: 12,
fontSize: 11,
lineHeight: 1.5,
color: body,
maxWidth: "94%",
}}
>
Подробнее <span></span>
</button>
</div>
) : (
// 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.
<>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 14,
}}
>
{card.delta ? (
<span
{card.emptyNote}
</div>
) : (
<>
<div
style={{
display: "inline-flex",
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 5,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 12,
fontWeight: 600,
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
// yet clearly below the accent headline (40).
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1,
// Contrast follow-up (audit #2266) — accentDeep, not
// accent, for the same reason as the title label above.
color: isHeadline
? accentDeep
: isRegistry
? muted
: isAsking
? ink
: undefined,
}}
>
{card.delta}
{card.value}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
{card.deltaLabel}
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</span>
) : (
<span />
)}
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
)}
{card.ppm && (
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
)}
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.note}
</div>
)}
</>
)}
{isEmptyCard ? (
<button
type="button"
className="rp-more"
@ -526,38 +440,151 @@ export default function ResultPanel({
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
width: "100%",
background: "none",
border: "none",
padding: 0,
margin: 0,
marginTop: 16,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
{card.exposureNote && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.exposureNote}
</div>
) : card.bars ? (
<>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 2,
height: 20,
flex: 1,
}}
>
{card.bars.map((h, bi) => (
<span
key={bi}
style={{
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
/>
))}
</div>
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
{card.delta ? (
<div style={{ marginTop: 8 }}>
<DeltaPill
delta={card.delta}
deltaLabel={card.deltaLabel}
/>
</div>
) : 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.
<>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 14,
}}
>
{card.delta ? (
<DeltaPill
delta={card.delta}
deltaLabel={card.deltaLabel}
/>
) : (
<span />
)}
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
{card.exposureNote && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.exposureNote}
</div>
)}
</>
)}
</>
)}
</div>
</div>
);
})}
</div>
@ -583,8 +610,8 @@ export default function ResultPanel({
marginTop: -6,
}}
>
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке,
а не погрешность оценки.
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
не погрешность оценки.
</div>
{/* ranges + radar */}

View file

@ -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<AggregatedEstimate, "market_percentile" | "n_analogs">,
): { 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)

View file

@ -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<string, number>; // {'avito': 12, 'cian': 5}
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
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 минут назад»
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;