feat(tradein): expected-sold price in estimate PDF (#648 S5b — final) #665

Merged
Light1YT merged 2 commits from feat/648-s5b-pdf-dual-display into main 2026-05-29 14:09:47 +00:00
3 changed files with 145 additions and 1 deletions
Showing only changes of commit 00484fc02f - Show all commits

View file

@ -73,6 +73,23 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
// ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
// null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
const sold = estimate.expected_sold_price_rub;
const hasSold = typeof sold === "number" && sold > 0;
const soldLo = estimate.expected_sold_range_low_rub;
const soldHi = estimate.expected_sold_range_high_rub;
const soldPerM2 = estimate.expected_sold_per_m2;
// Скидка запрос→продажа: из ratio при наличии, иначе из самих чисел. Только если ≥1%.
const discountPct = hasSold
? Math.round(
(typeof estimate.asking_to_sold_ratio === "number" && estimate.asking_to_sold_ratio > 0
? 1 - estimate.asking_to_sold_ratio
: 1 - (sold as number) / m) * 100,
)
: 0;
const showDiscount = hasSold && discountPct >= 1;
const showMockDisclaimer = isMockAddress(estimate);
// Progressive enrichment state
@ -237,6 +254,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
</div>
<div className={`hero-prices${hasSold ? "" : " single"}`}>
<div className="price-figure" data-kind="asking">
<div className="price-figure-label">Ориентир запроса</div>
<div className="price-figure-value mono">{formatMln(m)} </div>
<div className="price-figure-range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="price-figure-perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
{hasSold && (
<div className="price-figure" data-kind="sold">
<div className="price-figure-label">
Ожидаемая цена продажи
{showDiscount && (
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
{discountPct}%
</span>
)}
</div>
<div className="price-figure-value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="price-figure-range mono">
{formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="price-figure-perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
</div>
)}
</div>
{hasSold && (
<p className="hero-prices-note">
<b>Запрос</b> по чему выставлены сопоставимые квартиры в объявлениях.{" "}
<b>Ожидаемая цена продажи</b> реалистичная цена сделки по данным ДКП Росреестра, обычно
ниже запроса.
</p>
)}
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">

View file

@ -624,6 +624,75 @@
.meta-grid .meta-row .v { color: var(--fg); font-weight: 500; }
.meta-grid .meta-row .v.mono { font-family: var(--font-mono); }
/* Два равнозначных ценовых ориентира: запрос vs ожидаемая продажа (#648 S5a).
Одинаковый размер value у обоих ни один не доминирует. */
.hero-prices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid var(--border);
}
.hero-prices.single { grid-template-columns: 1fr; }
.price-figure {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.price-figure[data-kind="sold"] {
background: var(--accent-soft);
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
}
.price-figure-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--muted);
}
.price-figure[data-kind="sold"] .price-figure-label { color: var(--accent-ink); }
.price-figure-delta {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 14%, transparent);
color: var(--accent-ink);
}
.price-figure-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--fg);
}
.price-figure-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.price-figure-perm2 {
font-size: 11px;
color: var(--muted-2);
letter-spacing: 0.04em;
}
.hero-prices-note {
margin: 0;
padding: 12px 22px 0;
font-size: 12px;
line-height: 1.5;
color: var(--muted);
}
.hero-prices-note b { color: var(--fg-2); font-weight: 600; }
.hero-bars {
padding: 18px 22px 20px;
border-bottom: 1px solid var(--border);
@ -1070,6 +1139,7 @@
.layout { grid-template-columns: 1fr; }
.form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; }
.hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; }
.advantages { grid-template-columns: repeat(2, minmax(0, 1fr)); }

View file

@ -69,12 +69,24 @@ export interface CianValuationSummary {
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
}
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
export type RatioBasis = "per_rooms" | "global_fallback";
export interface AggregatedEstimate {
estimate_id: string; // UUID
median_price_rub: number;
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
range_low_rub: number;
range_high_rub: number;
median_price_per_m2: number;
// ── Ожидаемая цена продажи (S3) — реалистичная цена сделки по ДКП Росреестра,
// обычно ~на 1520% ниже asking. Все поля optional + nullable: старые оценки
// их не содержат, а graceful-degradation отдаёт null при пустой ratio-таблице.
expected_sold_price_rub?: number | null;
expected_sold_range_low_rub?: number | null;
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'
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;