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
5 changed files with 512 additions and 3 deletions

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

View file

@ -73,6 +73,23 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
// ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
// null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
const sold = estimate.expected_sold_price_rub;
const hasSold = typeof sold === "number" && sold > 0;
const soldLo = estimate.expected_sold_range_low_rub;
const soldHi = estimate.expected_sold_range_high_rub;
const soldPerM2 = estimate.expected_sold_per_m2;
// Скидка запрос→продажа: из ratio при наличии, иначе из самих чисел. Только если ≥1%.
const discountPct = hasSold
? Math.round(
(typeof estimate.asking_to_sold_ratio === "number" && estimate.asking_to_sold_ratio > 0
? 1 - estimate.asking_to_sold_ratio
: 1 - (sold as number) / m) * 100,
)
: 0;
const showDiscount = hasSold && discountPct >= 1;
const showMockDisclaimer = isMockAddress(estimate);
// Progressive enrichment state
@ -237,6 +254,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
</div>
<div className={`hero-prices${hasSold ? "" : " single"}`}>
<div className="price-figure" data-kind="asking">
<div className="price-figure-label">Ориентир запроса</div>
<div className="price-figure-value mono">{formatMln(m)} </div>
<div className="price-figure-range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="price-figure-perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
{hasSold && (
<div className="price-figure" data-kind="sold">
<div className="price-figure-label">
Ожидаемая цена продажи
{showDiscount && (
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
{discountPct}%
</span>
)}
</div>
<div className="price-figure-value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="price-figure-range mono">
{formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="price-figure-perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
</div>
)}
</div>
{hasSold && (
<p className="hero-prices-note">
<b>Запрос</b> по чему выставлены сопоставимые квартиры в объявлениях.{" "}
<b>Ожидаемая цена продажи</b> реалистичная цена сделки по данным ДКП Росреестра, обычно
ниже запроса.
</p>
)}
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">

View file

@ -624,6 +624,75 @@
.meta-grid .meta-row .v { color: var(--fg); font-weight: 500; }
.meta-grid .meta-row .v.mono { font-family: var(--font-mono); }
/* Два равнозначных ценовых ориентира: запрос vs ожидаемая продажа (#648 S5a).
Одинаковый размер value у обоих ни один не доминирует. */
.hero-prices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid var(--border);
}
.hero-prices.single { grid-template-columns: 1fr; }
.price-figure {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.price-figure[data-kind="sold"] {
background: var(--accent-soft);
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
}
.price-figure-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--muted);
}
.price-figure[data-kind="sold"] .price-figure-label { color: var(--accent-ink); }
.price-figure-delta {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 14%, transparent);
color: var(--accent-ink);
}
.price-figure-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--fg);
}
.price-figure-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.price-figure-perm2 {
font-size: 11px;
color: var(--muted-2);
letter-spacing: 0.04em;
}
.hero-prices-note {
margin: 0;
padding: 12px 22px 0;
font-size: 12px;
line-height: 1.5;
color: var(--muted);
}
.hero-prices-note b { color: var(--fg-2); font-weight: 600; }
.hero-bars {
padding: 18px 22px 20px;
border-bottom: 1px solid var(--border);
@ -1070,6 +1139,7 @@
.layout { grid-template-columns: 1fr; }
.form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; }
.hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; }
.advantages { grid-template-columns: repeat(2, minmax(0, 1fr)); }

View file

@ -69,12 +69,24 @@ export interface CianValuationSummary {
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
}
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
export type RatioBasis = "per_rooms" | "global_fallback";
export interface AggregatedEstimate {
estimate_id: string; // UUID
median_price_rub: number;
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
range_low_rub: number;
range_high_rub: number;
median_price_per_m2: number;
// ── Ожидаемая цена продажи (S3) — реалистичная цена сделки по ДКП Росреестра,
// обычно ~на 1520% ниже asking. Все поля optional + nullable: старые оценки
// их не содержат, а graceful-degradation отдаёт null при пустой ratio-таблице.
expected_sold_price_rub?: number | null;
expected_sold_range_low_rub?: number | null;
expected_sold_range_high_rub?: number | null;
expected_sold_per_m2?: number | null;
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;