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
2 changed files with 367 additions and 2 deletions
Showing only changes of commit 6545568778 - Show all commits

View file

@ -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"<span style='display:inline-block;margin-left:6pt;padding:1pt 6pt;"
f"background:{accent};color:#fff;font-size:8pt;font-weight:700;"
f"border-radius:8pt;vertical-align:middle;'>&#8722;{delta_pct}%</span>"
)
range_html = ""
if range_low is not None and range_high is not None and range_high > range_low:
range_html = (
f"<div style='font-size:10pt;color:#6b7280;font-weight:600;margin-top:3pt;"
f"font-variant-numeric:tabular-nums;'>"
f"{_html.escape(_fmt_rub_m(range_low))} &#8211; {_html.escape(_fmt_rub_m(range_high))}"
f"</div>"
)
perm2_html = ""
if per_m2 is not None and per_m2 > 0:
perm2_html = (
f"<div style='font-size:9pt;color:#9ca3af;margin-top:2pt;"
f"font-variant-numeric:tabular-nums;'>{_fmt_ppm2(per_m2)} &#8381;/м²</div>"
)
return (
f"<td style='width:50%;vertical-align:top;padding:10pt 12pt;background:#f9fafb;"
f"border-top:2pt solid {accent};border-radius:4pt;'>"
f"<div style='font-size:9pt;color:#6b7280;font-weight:700;text-transform:uppercase;"
f"letter-spacing:0.03em;'>{_html.escape(label)}{delta_chip}</div>"
f"<div style='font-size:18pt;font-weight:900;color:#1a1d23;margin-top:4pt;"
f"font-variant-numeric:tabular-nums;'>{_html.escape(_fmt_rub_m(value_rub))}</div>"
f"{range_html}{perm2_html}"
f"</td>"
)
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'<table style="width:100%;border-collapse:separate;border-spacing:0;'
f'margin:8pt 0 4pt 0;"><tr>{single}</tr></table>'
)
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 = (
'<p style="margin:6pt 0 4pt 0;font-size:8.5pt;color:#6b7280;line-height:1.4;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса."
"</p>"
)
return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
f'margin:8pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f"{explainer}"
)
# ── Page 1: Cover ────────────────────────────────────────────────────────────
@ -342,7 +462,10 @@ def _build_cover(
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">
<!-- Две равновесные цены: ориентир запроса vs ожидаемая цена продажи (#648 S5b) -->
{_dual_price_block(estimate, brand)}
<p style="margin:10pt 0 4pt 0;font-size:10pt;font-weight:700;">
Диапазон цен в объявлениях
<span style="font-weight:400;font-size:9pt;color:#6b7280;">(без учета ремонта)</span>
</p>
@ -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:

View file

@ -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"<root>{fragment}</root>")
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 "&#8722;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 "&#8722;" 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