This commit is contained in:
parent
7d749f4080
commit
870f062587
2 changed files with 367 additions and 2 deletions
|
|
@ -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;'>−{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))} – {_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)} ₽/м²</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:
|
||||
|
|
|
|||
|
|
@ -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 "−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
|
||||
Loading…
Add table
Reference in a new issue