From 870f0625872be42737510aa8bdcd7c9a54486aaf Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 29 May 2026 14:09:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20expected-sold=20price=20in=20e?= =?UTF-8?q?stimate=20PDF=20(#648=20S5b=20=E2=80=94=20final)=20(#665)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/exporters/trade_in_pdf.py | 132 +++++++++- .../services/test_trade_in_pdf_dual_price.py | 237 ++++++++++++++++++ 2 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py 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