feat(tradein): expected-sold price in estimate PDF (#648 S5b — final) (#665)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy Trade-In / deploy (push) Successful in 35s

This commit is contained in:
Light1YT 2026-05-29 14:09:46 +00:00
parent 7d749f4080
commit 870f062587
2 changed files with 367 additions and 2 deletions

View file

@ -22,7 +22,6 @@ from uuid import UUID
import segno import segno
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from weasyprint import CSS, HTML
from app.core.config import settings from app.core.config import settings
from app.schemas.trade_in import AggregatedEstimate, AnalogLot 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 ──────────────────────────────────────────────────────────── # ── Page 1: Cover ────────────────────────────────────────────────────────────
@ -342,7 +462,10 @@ def _build_cover(
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr> <tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
</table> </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> <span style="font-weight:400;font-size:9pt;color:#6b7280;">(без учета ремонта)</span>
</p> </p>
@ -988,6 +1111,11 @@ def generate_trade_in_pdf(
Returns: Returns:
PDF bytes для Response(media_type="application/pdf") 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 from app.services.brand import Brand
if brand is None: 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