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'{single}
' + ) + + 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'{asking_cell}{sold_cell}
' + 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;