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, font,
} = tokens; } = 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 { interface ResultPanelProps {
// Required on the app path (v2/page.tsx always supplies mapResultPanel // 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 // 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 isHeadline = !isEmptyCard && ci === headlineIndex;
const isAsking = ci === 0; const isAsking = ci === 0;
return ( 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 <div
key={ci}
style={{ style={{
position: "absolute", position: "relative",
left: -1, background: isHeadline
top: -1, ? infoSoftBg
width: 12, : isRegistry
height: 12, ? surface.w40
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`, : surface.w55,
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`, backdropFilter: "blur(6px)",
}} border: isHeadline
/> ? `1.5px solid ${accent}`
<div : isRegistry
style={{ ? `1px solid ${lineSoft}`
display: "flex", : `1px solid ${line2}`,
alignItems: "flex-start", borderRadius: 8,
justifyContent: "space-between", padding: "15px 16px 13px",
gap: 8, boxShadow: isHeadline
? "0 6px 22px rgba(46,139,255,.16)"
: undefined,
}} }}
> >
<div <div
style={{ style={{
fontSize: 9.5, position: "absolute",
letterSpacing: 1.5, left: -1,
// Contrast follow-up (audit #2266): accent (#2e8bff) on the top: -1,
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below width: 12,
// even the AA "large text" floor (3:1). accentDeep is the same height: 12,
// brand hue, darkened (existing token, not a one-off hex) — it borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
// clears ~4.2:1 here, so the label reads reliably too. borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2, }}
fontWeight: isHeadline ? 600 : undefined, />
lineHeight: 1.6, <div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}} }}
> >
{lines(card.title)} <div
</div> style={{
{/* #1991 explicit "this is the figure that goes into the offer" 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 mark on the headline card, so the two dominant prices are not
just visually bigger but literally labelled which one to quote. */} just visually bigger but literally labelled which one to quote. */}
{isHeadline && !isEmptyCard && ( {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 <span
style={{ style={{
fontFamily: font.mono, flex: "0 0 auto",
// M1 — asking (30) sits above the ДКП «справочно» tier (28) fontSize: 8,
// yet clearly below the accent headline (40). fontWeight: 700,
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, letterSpacing: 1,
color: onAccent,
background: accentDeep,
borderRadius: 3,
padding: "3px 6px",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{card.unit} В ОФФЕР
</span> </span>
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
)} )}
{card.ppm && ( </div>
<div {isRegistry && (
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 <div
style={{ style={{
display: "flex", fontSize: 8,
alignItems: "flex-end", letterSpacing: 1.2,
gap: 2, color: muted3,
height: 20, marginTop: 3,
flex: 1,
}} }}
> >
{card.bars.map((h, bi) => ( {/* §M8 — расшифровка «ДКП» один раз на секцию. */}
<span СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
key={bi}
style={{
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
/>
))}
</div> </div>
<button )}
type="button" {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» just an
className="rp-more" honest note pointing at the recommended asking price. */}
onClick={() => onNavigate(card.nav)} {isEmptyCard ? (
aria-label={`Подробнее — ${card.title.join(" ")}`} <div
style={{ style={{
display: "flex", marginTop: 12,
alignItems: "center", fontSize: 11,
gap: 4, lineHeight: 1.5,
cursor: "pointer", color: body,
fontSize: 9, maxWidth: "94%",
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}} }}
> >
Подробнее <span></span> {card.emptyNote}
</button> </div>
</div> ) : (
) : ( <>
// M2 — calm delta pill (was a 51px circular gauge that read like a <div
// 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
style={{ style={{
display: "inline-flex", marginTop: 14,
display: "flex",
alignItems: "baseline", alignItems: "baseline",
gap: 5, gap: 6,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}} }}
> >
<span <span
style={{ style={{
fontFamily: font.mono, fontFamily: font.mono,
fontSize: 12, // M1 — asking (30) sits above the ДКП «справочно» tier (28)
fontWeight: 600, // 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>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}> <span
{card.deltaLabel} style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span> </span>
</span> </div>
) : ( {card.range && (
<span /> <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 <button
type="button" type="button"
className="rp-more" className="rp-more"
@ -526,38 +440,151 @@ export default function ResultPanel({
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end",
gap: 4, gap: 4,
cursor: "pointer", cursor: "pointer",
fontSize: 9, fontSize: 9,
letterSpacing: 0.3, letterSpacing: 0.3,
color: accent, color: accent,
whiteSpace: "nowrap", width: "100%",
background: "none", background: "none",
border: "none", border: "none",
padding: 0, padding: 0,
margin: 0, margin: 0,
marginTop: 16,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
> >
Подробнее <span></span> Подробнее <span></span>
</button> </button>
</div> ) : card.bars ? (
{card.exposureNote && ( <>
<div <div
style={{ style={{
fontSize: 9, display: "flex",
color: muted2, alignItems: "flex-end",
marginTop: 4, justifyContent: "space-between",
lineHeight: 1.4, gap: 12,
maxWidth: "82%", marginTop: 12,
}} }}
> >
{card.exposureNote} <div
</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> </div>
@ -583,8 +610,8 @@ export default function ResultPanel({
marginTop: -6, marginTop: -6,
}} }}
> >
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
а не погрешность оценки. не погрешность оценки.
</div> </div>
{/* ranges + radar */} {/* 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; let best: AnalogLot | null = null;
for (const l of lots) { 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]; 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 // 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 // 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. // 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 сделкам, // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
// так что максимум по ним = максимум по всей выборке, не по показанным. // так что максимум по ним = максимум по всей выборке, не по показанным.
asOf: dealsAsOfLabel(...newestLot(sd.deals)), asOf: dealsAsOfLabel(...newestLot(sd.deals)),
@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report {
* - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg) * - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" "мало данных" (too few comparable listings) * - "insufficient_data" "мало данных" (too few comparable listings)
*/ */
function locationIndexBadge( function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
li: LocationIndexResponse | null | undefined, label: string;
): { label: string; ok: boolean } { ok: boolean;
} {
if (li == null) return { label: "нет данных", ok: false }; 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 }; if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data" 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. */ /** 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( export function mapResultPanel(
e: AggregatedEstimate, e: AggregatedEstimate,
streetDeals?: StreetDealsResponse | null, streetDeals?: StreetDealsResponse | null,
@ -1108,7 +1135,12 @@ export function mapResultPanel(
// 872k выброс this card already reports as "исключён", squashing the real // 872k выброс this card already reports as "исключён", squashing the real
// analogs into the left bins while the number says the outlier is dropped. // analogs into the left bins while the number says the outlier is dropped.
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn). // 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, nav: 2,
}, },
{ {
@ -1121,7 +1153,9 @@ export function mapResultPanel(
e.expected_sold_range_high_rub, e.expected_sold_range_high_rub,
) )
: "", : "",
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", ppm: hasExpected
? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`
: "",
delta: delta:
hasExpected && e.asking_to_sold_ratio != null hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100) ? 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 // sample still shown in the table right below this KPI tile) — a bare
// "0" here would directly contradict visible rows. Fall back to the // "0" here would directly contradict visible rows. Fall back to the
// actual displayed population (same fix as ListingsCard's count-strip). // 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) : "—", median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm: ppm:
e != null && Number.isFinite(e.median_price_per_m2) e != null && Number.isFinite(e.median_price_per_m2)

View file

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