diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index 6b3924fa..9f5f1a63 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -22,7 +22,6 @@ from uuid import UUID
import segno
from PIL import Image, ImageDraw
-from weasyprint import CSS, HTML
from app.core.config import settings
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
@@ -244,6 +243,127 @@ def _price_range_bar(
)
+# ── Dual price headline: «Ориентир запроса» + «Ожидаемая цена продажи» ───────
+# Зеркалит web HeroSummary (#648 S5a): две равновесные цифры. Asking — медиана
+# по объявлениям; expected_sold — реалистичная цена сделки (asking × ratio из
+# asking_to_sold_ratios, migration 080). Graceful: при отсутствии sold (None/0 —
+# старые оценки / пустой бакет ratio-таблицы) рисуем ТОЛЬКО asking-колонку.
+
+
+def _discount_pct(estimate: AggregatedEstimate) -> int | None:
+ """Скидка запрос→продажа в % (как web): из asking_to_sold_ratio при наличии,
+ иначе из самих чисел sold/median. None если sold нет или скидка < 1%."""
+ sold = estimate.expected_sold_price_rub
+ if not sold or sold <= 0:
+ return None
+ ratio = estimate.asking_to_sold_ratio
+ if ratio is not None and ratio > 0:
+ pct = round((1 - ratio) * 100)
+ elif estimate.median_price_rub > 0:
+ pct = round((1 - sold / estimate.median_price_rub) * 100)
+ else:
+ return None
+ return pct if pct >= 1 else None
+
+
+def _price_figure_cell(
+ *,
+ label: str,
+ value_rub: int,
+ range_low: int | None,
+ range_high: int | None,
+ per_m2: int | None,
+ accent: str,
+ delta_pct: int | None = None,
+) -> str:
+ """Одна ценовая колонка (label + крупное число + диапазон + ₽/м²).
+
+ Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении —
+ как web (guard на typeof number). delta_pct — опциональный chip «−N%»."""
+ delta_chip = ""
+ if delta_pct is not None:
+ delta_chip = (
+ f"−{delta_pct}%"
+ )
+ range_html = ""
+ if range_low is not None and range_high is not None and range_high > range_low:
+ range_html = (
+ f"
"
+ f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}"
+ f"
"
+ )
+ perm2_html = ""
+ if per_m2 is not None and per_m2 > 0:
+ perm2_html = (
+ f"{_fmt_ppm2(per_m2)} ₽/м²
"
+ )
+ return (
+ f""
+ f" {_html.escape(label)}{delta_chip} "
+ f"{_html.escape(_fmt_rub_m(value_rub))} "
+ f"{range_html}{perm2_html}"
+ f" | "
+ )
+
+
+def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
+ """Две равновесные цены: «Ориентир запроса» и «Ожидаемая цена продажи».
+
+ Graceful: если expected_sold_price_rub None/0 — одиночная колонка (asking),
+ explainer/chip скрыты (текущее поведение PDF для старых оценок)."""
+ accent = brand.primary_color if brand else "#1d4ed8"
+ sold = estimate.expected_sold_price_rub
+ has_sold = bool(sold) and sold > 0
+
+ asking_cell = _price_figure_cell(
+ label="Ориентир запроса",
+ value_rub=estimate.median_price_rub,
+ range_low=estimate.range_low_rub,
+ range_high=estimate.range_high_rub,
+ per_m2=estimate.median_price_per_m2,
+ accent=accent,
+ )
+
+ if not has_sold:
+ # Одиночная колонка — растягиваем на всю ширину (graceful fallback).
+ single = asking_cell.replace("width:50%", "width:100%", 1)
+ return (
+ f''
+ )
+
+ sold_cell = _price_figure_cell(
+ label="Ожидаемая цена продажи",
+ value_rub=sold,
+ range_low=estimate.expected_sold_range_low_rub,
+ range_high=estimate.expected_sold_range_high_rub,
+ per_m2=estimate.expected_sold_per_m2,
+ accent="#dc2626",
+ delta_pct=_discount_pct(estimate),
+ )
+
+ explainer = (
+ ''
+ "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. "
+ "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП "
+ "Росреестра, обычно ниже запроса."
+ "
"
+ )
+
+ return (
+ f''
+ f"{explainer}"
+ )
+
+
# ── Page 1: Cover ────────────────────────────────────────────────────────────
@@ -342,7 +462,10 @@ def _build_cover(
| Балкон / лоджия | {balcony_label} |
-
+
+ {_dual_price_block(estimate, brand)}
+
+
Диапазон цен в объявлениях
(без учета ремонта)
@@ -988,6 +1111,11 @@ def generate_trade_in_pdf(
Returns:
PDF bytes для Response(media_type="application/pdf")
"""
+ # WeasyPrint импортируется лениво: его native-зависимости (pango/cairo) нужны
+ # только для финального write_pdf — HTML-билдеры (cover/listings/...) работают
+ # без них, что делает рендер-функции тестируемыми в окружениях без native libs.
+ from weasyprint import CSS, HTML
+
from app.services.brand import Brand
if brand is None:
diff --git a/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py
new file mode 100644
index 00000000..ac78775a
--- /dev/null
+++ b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py
@@ -0,0 +1,237 @@
+"""#648 S5b — dual-price display in the estimate PDF.
+
+Mirrors the web HeroSummary (S5a): the cover must show BOTH «Ориентир запроса»
+(asking median) and «Ожидаемая цена продажи» (expected_sold) with equal
+prominence, the explainer, and a −N% discount chip — and gracefully fall back
+to the asking figure alone when expected_sold is None/0 (old estimates / empty
+ratio bucket).
+
+Tests target the pure HTML builders (`_discount_pct`, `_dual_price_block`,
+`_build_cover`), which do not require WeasyPrint's native libs — the import of
+WeasyPrint inside `generate_trade_in_pdf` is lazy, so the HTML-building path is
+exercisable without the GTK/pango/cairo stack (unavailable in CI / on macOS).
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from datetime import UTC, datetime, timedelta
+from html.parser import HTMLParser
+from typing import ClassVar
+from unittest.mock import MagicMock
+from uuid import uuid4
+
+# DATABASE_URL required by config before any app import (см. test_dadata.py).
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+# WeasyPrint stub — not installed in CI без GTK (consistent с test_dadata.py).
+# The render-path import is lazy, but settings/other modules may still touch it.
+_wp_mock = MagicMock()
+sys.modules.setdefault("weasyprint", _wp_mock)
+
+from app.schemas.trade_in import AggregatedEstimate # noqa: E402
+from app.services.brand import Brand # noqa: E402
+from app.services.exporters import trade_in_pdf as mod # noqa: E402
+
+_BRAND = Brand(
+ slug="generic",
+ name="Trade-In",
+ logo_url=None,
+ primary_color="#1d4ed8",
+ accent_color="#f59e0b",
+ footer_text=None,
+ pdf_disclaimer=None,
+)
+
+_SNAPSHOT = {
+ "address": "Екатеринбург, ул. Ленина, 1",
+ "area_m2": 50.0,
+ "rooms": 2,
+ "floor": 3,
+ "total_floors": 9,
+ "year_built": 2010,
+ "house_type": "panel",
+ "repair_state": "standard",
+ "has_balcony": True,
+}
+
+
+def _estimate(**overrides) -> AggregatedEstimate:
+ base = dict(
+ estimate_id=uuid4(),
+ median_price_rub=10_000_000,
+ range_low_rub=9_000_000,
+ range_high_rub=11_000_000,
+ median_price_per_m2=200_000,
+ confidence="high",
+ n_analogs=15,
+ period_months=24,
+ analogs=[],
+ actual_deals=[],
+ expires_at=datetime.now(UTC) + timedelta(days=30),
+ )
+ base.update(overrides)
+ return AggregatedEstimate(**base)
+
+
+class _WellFormed(HTMLParser):
+ """Minimal balanced-tag check: every non-void tag that opens must close."""
+
+ _VOID: ClassVar[set[str]] = {
+ "br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
+ }
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.stack: list[str] = []
+ self.ok = True
+
+ def handle_starttag(self, tag: str, attrs: list) -> None:
+ if tag not in self._VOID:
+ self.stack.append(tag)
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag in self._VOID:
+ return
+ if self.stack and self.stack[-1] == tag:
+ self.stack.pop()
+ elif tag in self.stack:
+ # tolerate minor nesting slack but flag truly broken close
+ while self.stack and self.stack[-1] != tag:
+ self.stack.pop()
+ if self.stack:
+ self.stack.pop()
+ else:
+ self.ok = False
+
+
+def _assert_well_formed(fragment: str) -> None:
+ p = _WellFormed()
+ p.feed(f"{fragment}")
+ p.close()
+ assert p.ok, "unbalanced tags in HTML fragment"
+
+
+# ── _discount_pct ────────────────────────────────────────────────────────────
+
+
+def test_discount_pct_from_ratio() -> None:
+ # ratio 0.74 → −26%
+ est = _estimate(expected_sold_price_rub=7_400_000, asking_to_sold_ratio=0.74)
+ assert mod._discount_pct(est) == 26
+
+
+def test_discount_pct_from_numbers_when_no_ratio() -> None:
+ # sold/median = 9.0/10.0 → −10%
+ est = _estimate(expected_sold_price_rub=9_000_000, asking_to_sold_ratio=None)
+ assert mod._discount_pct(est) == 10
+
+
+def test_discount_pct_none_when_no_sold() -> None:
+ assert mod._discount_pct(_estimate(expected_sold_price_rub=None)) is None
+ assert mod._discount_pct(_estimate(expected_sold_price_rub=0)) is None
+
+
+def test_discount_pct_none_when_below_one_percent() -> None:
+ # ratio 0.997 → rounds to 0% → hidden
+ est = _estimate(expected_sold_price_rub=9_970_000, asking_to_sold_ratio=0.997)
+ assert mod._discount_pct(est) is None
+
+
+# ── _dual_price_block ──────────────────────────────────────────────────────────
+
+
+def test_dual_block_with_sold_shows_both_figures_and_explainer() -> None:
+ est = _estimate(
+ expected_sold_price_rub=8_000_000,
+ expected_sold_range_low_rub=7_500_000,
+ expected_sold_range_high_rub=8_500_000,
+ expected_sold_per_m2=160_000,
+ asking_to_sold_ratio=0.80,
+ )
+ html = mod._dual_price_block(est, _BRAND)
+ _assert_well_formed(html)
+ assert "Ориентир запроса" in html
+ assert "Ожидаемая цена продажи" in html
+ # explainer wording (web parity)
+ assert "реалистичная цена сделки по данным ДКП" in html
+ assert "обычно ниже запроса" in html
+ # discount chip −20% (minus sign rendered as entity)
+ assert "−20%" in html
+ # both asking + sold values present (10.0 / 8.0 млн)
+ assert "10,0 млн. руб." in html
+ assert "8,0 млн. руб." in html
+ # sold range + per-m² present
+ assert "7,5 млн. руб." in html and "8,5 млн. руб." in html
+ assert "160 000" in html # expected_sold_per_m2 via _fmt_ppm2
+
+
+def test_dual_block_graceful_without_sold_is_single_figure() -> None:
+ html = mod._dual_price_block(_estimate(expected_sold_price_rub=None), _BRAND)
+ _assert_well_formed(html)
+ assert "Ориентир запроса" in html
+ # no sold column / explainer / chip
+ assert "Ожидаемая цена продажи" not in html
+ assert "ДКП" not in html
+ assert "−" not in html
+ # single column stretched full-width
+ assert "width:100%" in html
+ assert "width:50%" not in html
+
+
+def test_dual_block_zero_sold_treated_as_absent() -> None:
+ html = mod._dual_price_block(_estimate(expected_sold_price_rub=0), _BRAND)
+ assert "Ожидаемая цена продажи" not in html
+
+
+def test_dual_block_guards_each_subfield_independently() -> None:
+ # sold present but range/per-m² missing — value still shown, sub-rows omitted
+ est = _estimate(
+ expected_sold_price_rub=8_000_000,
+ expected_sold_range_low_rub=None,
+ expected_sold_range_high_rub=None,
+ expected_sold_per_m2=None,
+ asking_to_sold_ratio=0.80,
+ )
+ html = mod._dual_price_block(est, _BRAND)
+ _assert_well_formed(html)
+ # sold headline value still shown
+ assert "Ожидаемая цена продажи" in html
+ assert "8,0 млн. руб." in html
+ # asking column keeps its own per-m² (200 000); sold per-m² omitted entirely
+ assert "200 000" in html
+ assert "160 000" not in html
+ # sold range absent (low/high were None) — only the asking range 9/11 млн remains
+ assert "7,5 млн. руб." not in html and "8,5 млн. руб." not in html
+ assert "9,0 млн. руб." in html and "11,0 млн. руб." in html
+
+
+# ── _build_cover integration (HTML only, no WeasyPrint render) ─────────────────
+
+
+def test_build_cover_with_sold_contains_dual_block() -> None:
+ est = _estimate(
+ expected_sold_price_rub=8_000_000,
+ expected_sold_range_low_rub=7_500_000,
+ expected_sold_range_high_rub=8_500_000,
+ expected_sold_per_m2=160_000,
+ asking_to_sold_ratio=0.80,
+ )
+ html = mod._build_cover(est, _SNAPSHOT, _BRAND)
+ _assert_well_formed(html)
+ assert "Ориентир запроса" in html
+ assert "Ожидаемая цена продажи" in html
+ # existing sections untouched
+ assert "Диапазон цен в объявлениях" in html
+ assert "Диапазон цен по фактическим сделкам" in html
+
+
+def test_build_cover_without_sold_renders_only_asking() -> None:
+ html = mod._build_cover(_estimate(), _SNAPSHOT, _BRAND)
+ _assert_well_formed(html)
+ assert "Ориентир запроса" in html
+ assert "Ожидаемая цена продажи" not in html
+ # existing layout intact
+ assert "Диапазон цен в объявлениях" in html
+ assert "Диапазон цен по фактическим сделкам" in html
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index ee72f119..969aa3a9 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -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
+
+
+
Ориентир запроса
+
{formatMln(m)} ₽
+
+ {formatMln(lo)} – {formatMln(hi)} ₽
+
+
+ {estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
+
+
+
+ {hasSold && (
+
+
+ Ожидаемая цена продажи
+ {showDiscount && (
+
+ −{discountPct}%
+
+ )}
+
+
{formatMln(sold as number)} ₽
+ {typeof soldLo === "number" && typeof soldHi === "number" && (
+
+ {formatMln(soldLo)} – {formatMln(soldHi)} ₽
+
+ )}
+ {typeof soldPerM2 === "number" && (
+
+ {soldPerM2.toLocaleString("ru-RU")} ₽/м²
+
+ )}
+
+ )}
+
+
+ {hasSold && (
+
+ Запрос — по чему выставлены сопоставимые квартиры в объявлениях.{" "}
+ Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП Росреестра, обычно
+ ниже запроса.
+
+ )}
+
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
index 28bb70e0..6bbe0280 100644
--- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css
+++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
@@ -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)); }
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 4ade4328..f6aa9ad6 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -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) — реалистичная цена сделки по ДКП Росреестра,
+ // обычно ~на 15–20% ниже 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;