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"
' + "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " + "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " + "Росреестра, обычно ниже запроса." + "
" + ) + + return ( + f'+ + {_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"