chore(tradein/v2): #2899 — убрать шум prettier из предыдущего коммита (у tradein-frontend нет prettier-конвенции), оставить только правки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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 1m17s

This commit is contained in:
bot-backend 2026-08-21 17:01:25 +05:00
parent 03af7386e9
commit b6ab582211
3 changed files with 353 additions and 377 deletions

View file

@ -64,7 +64,13 @@ function DeltaPill({
whiteSpace: "nowrap",
}}
>
<span style={{ fontFamily: font.mono, fontSize: 12, fontWeight: 600 }}>
<span
style={{
fontFamily: font.mono,
fontSize: 12,
fontWeight: 600,
}}
>
{delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
@ -240,198 +246,250 @@ 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: "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,
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,
}}
>
<div
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}`,
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,
}}
>
<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,
letterSpacing: 1,
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-start",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 8,
gap: 12,
marginTop: 12,
}}
>
<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,
display: "flex",
alignItems: "flex-end",
gap: 2,
height: 20,
flex: 1,
}}
>
{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,
}}
>
{card.bars.map((h, bi) => (
<span
key={bi}
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,
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
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 ? (
/>
))}
</div>
<button
type="button"
className="rp-more"
@ -440,151 +498,89 @@ export default function ResultPanel({
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
width: "100%",
whiteSpace: "nowrap",
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
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-й
</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>
)}
</>
{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>
@ -610,8 +606,8 @@ export default function ResultPanel({
marginTop: -6,
}}
>
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
не погрешность оценки.
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке,
а не погрешность оценки.
</div>
{/* ranges + radar */}

View file

@ -605,16 +605,10 @@ interface DealTier {
}
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
function newestLot(
lots: AnalogLot[],
): [string | null, "day" | "quarter" | null] {
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
let best: AnalogLot | null = null;
for (const l of lots) {
if (
l.listing_date &&
(!best?.listing_date || l.listing_date > best.listing_date)
)
best = l;
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
}
return [best?.listing_date ?? null, best?.date_precision ?? null];
}
@ -642,9 +636,7 @@ function resolveDealTier(
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
bars: bins8(
guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean,
),
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
// так что максимум по ним = максимум по всей выборке, не по показанным.
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
@ -833,13 +825,11 @@ export function mapReport(e: AggregatedEstimate): Report {
* - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" "мало данных" (too few comparable listings)
*/
function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
label: string;
ok: boolean;
} {
function locationIndexBadge(
li: LocationIndexResponse | null | undefined,
): { label: string; ok: boolean } {
if (li == null) return { label: "нет данных", ok: false };
if (li.status === "ok")
return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data"
}
@ -1086,7 +1076,8 @@ export function marketPositionPill(
if (pct == null || !Number.isFinite(pct)) return undefined;
const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке";
const n = e.n_analogs;
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`;
const forms: [string, string, string] = ["аналога", "аналогов", "аналогов"];
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, forms)}`;
return { delta, deltaLabel };
}
@ -1135,9 +1126,7 @@ export function mapResultPanel(
// 872k выброс this card already reports as "исключён", squashing the real
// analogs into the left bins while the number says the outlier is dropped.
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
bars: bins8(
guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean,
),
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
// #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная
// плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null).
...(marketPositionPill(e) ?? {}),
@ -1153,9 +1142,7 @@ export function mapResultPanel(
e.expected_sold_range_high_rub,
)
: "",
ppm: hasExpected
? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`
: "",
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
delta:
hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
@ -2182,10 +2169,7 @@ export function mapSources(
// sample still shown in the table right below this KPI tile) — a bare
// "0" here would directly contradict visible rows. Fall back to the
// actual displayed population (same fix as ListingsCard's count-strip).
count:
e != null
? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length)
: "—",
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—",
median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm:
e != null && Number.isFinite(e.median_price_per_m2)

View file

@ -10,7 +10,11 @@ export interface QuotaStatus {
}
export type HouseType =
"panel" | "brick" | "monolith" | "monolith_brick" | "other";
| "panel"
| "brick"
| "monolith"
| "monolith_brick"
| "other";
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
@ -29,19 +33,11 @@ export const REPAIR_STATES: readonly RepairState[] = [
"good",
"excellent",
];
export function asHouseType(
v: string | null | undefined,
): HouseType | undefined {
return v && (HOUSE_TYPES as readonly string[]).includes(v)
? (v as HouseType)
: undefined;
export function asHouseType(v: string | null | undefined): HouseType | undefined {
return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined;
}
export function asRepairState(
v: string | null | undefined,
): RepairState | undefined {
return v && (REPAIR_STATES as readonly string[]).includes(v)
? (v as RepairState)
: undefined;
export function asRepairState(v: string | null | undefined): RepairState | undefined {
return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined;
}
export type ConfidenceLevel = "low" | "medium" | "high";
@ -107,9 +103,9 @@ export interface AnalogLot {
days_on_market: number | null;
photo_url: string | null;
// ── Слой 5.2 — clickable links + distance ──
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
source_url: string | null; // ссылка на оригинал
distance_m: number | null; // расстояние до целевой квартиры в метрах
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
source_url: string | null; // ссылка на оригинал
distance_m: number | null; // расстояние до целевой квартиры в метрах
// ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ──
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
// 'T1_per_street' — street-level only (default for all rosreestr deals)
@ -129,7 +125,7 @@ export interface AnalogLot {
// month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району.
export interface PriceTrendPoint {
month: string; // "YYYY-MM"
ppm2: number; // ₽/м²
ppm2: number; // ₽/м²
}
export interface CianValuationSummary {
@ -137,7 +133,7 @@ export interface CianValuationSummary {
rent_price_rub: number | null;
chart: Array<{ date: string; price: number }>;
chart_change_pct: number | null;
chart_change_direction: "increase" | "decrease" | "neutral" | null;
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
}
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
@ -148,20 +144,20 @@ export type RatioBasis = "per_rooms" | "global_fallback";
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
export interface AvitoImvSummary {
recommended_price: number | null; // рекомендованная цена Avito, ₽
lower_price: number | null; // нижняя граница IMV-коридора, ₽
higher_price: number | null; // верхняя граница IMV-коридора, ₽
market_count: number | null; // объём рынка, на котором построена оценка
lower_price: number | null; // нижняя граница IMV-коридора, ₽
higher_price: number | null; // верхняя граница IMV-коридора, ₽
market_count: number | null; // объём рынка, на котором построена оценка
}
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
export interface DkpCorridor {
count: number; // число ДКП-сделок в выборке
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
count: number; // число ДКП-сделок в выборке
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
@ -183,7 +179,7 @@ export interface AggregatedEstimate {
expected_sold_range_high_rub?: number | null;
expected_sold_per_m2?: number | null;
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;
@ -218,16 +214,16 @@ export interface AggregatedEstimate {
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
target_city_ambiguous?: boolean;
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
// кешированные оценки их не содержат → UI graceful fallback.
cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен
source_counts?: Record<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: населённый пункт/город/регион/не распознан).
@ -391,15 +387,15 @@ export interface HouseAnalyticsResponse {
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
export interface StreetDealsResponse {
street: string | null; // "Космонавтов" / null если не извлёкся
period_from: string; // ISO date "2025-05-29"
street: string | null; // "Космонавтов" / null если не извлёкся
period_from: string; // ISO date "2025-05-29"
period_to: string;
count: number; // all matching, не топ-10
count: number; // all matching, не топ-10
median_price_rub: number;
median_price_per_m2: number;
range_low_rub: number;
range_high_rub: number;
deals: AnalogLot[]; // top-10 по deal_date DESC
deals: AnalogLot[]; // top-10 по deal_date DESC
}
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
@ -408,7 +404,7 @@ export interface StreetDealsResponse {
export interface SalesListingPair {
deal_id: number;
deal_date: string; // ISO date
deal_date: string; // ISO date
deal_price_rub: number;
deal_price_per_m2: number;
deal_area_m2: number;
@ -417,9 +413,9 @@ export interface SalesListingPair {
deal_address: string;
listing_id: number | null;
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
listing_source_url: string | null;
listing_date: string | null; // ISO date
listing_date: string | null; // ISO date
listing_price_rub: number | null;
listing_price_per_m2: number | null;
listing_area_m2: number | null;
@ -434,9 +430,9 @@ export interface SalesListingPair {
export interface SalesVsListingsResponse {
street: string | null;
period_months: number; // 24 default
window_days: number; // 180 default
area_tolerance: number; // 0.15 default
period_months: number; // 24 default
window_days: number; // 180 default
area_tolerance: number; // 0.15 default
total_deals: number;
deals_with_listings: number;
linkage_rate_pct: number;
@ -448,14 +444,14 @@ export interface SalesVsListingsResponse {
// Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть
// сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок.
data_quality: "house_linked" | "street_only" | "no_data";
pairs: SalesListingPair[]; // все sorted by deal_date DESC
pairs: SalesListingPair[]; // все sorted by deal_date DESC
}
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
export interface SellTimeBucket {
price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10'
price_premium_pct: number; // -5, 0, 5, 10
price_premium_pct: number; // -5, 0, 5, 10
median_exposure_days: number | null;
p25_days: number | null;
p75_days: number | null;