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 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

View file

@ -73,6 +73,23 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const lo = estimate.range_low_rub; const lo = estimate.range_low_rub;
const hi = estimate.range_high_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); const showMockDisclaimer = isMockAddress(estimate);
// Progressive enrichment state // Progressive enrichment state
@ -237,6 +254,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div> </div>
</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="hero-bars">
<div className="bar-block"> <div className="bar-block">
<div className="bar-head"> <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 { color: var(--fg); font-weight: 500; }
.meta-grid .meta-row .v.mono { font-family: var(--font-mono); } .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 { .hero-bars {
padding: 18px 22px 20px; padding: 18px 22px 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@ -1070,6 +1139,7 @@
.layout { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; }
.form-card { position: static; max-height: none; } .form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; } .hero-top { grid-template-columns: 1fr; }
.hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; } .hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; } .progress-list { grid-template-columns: 1fr; }
.advantages { grid-template-columns: repeat(2, minmax(0, 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; chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
} }
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
export type RatioBasis = "per_rooms" | "global_fallback";
export interface AggregatedEstimate { export interface AggregatedEstimate {
estimate_id: string; // UUID estimate_id: string; // UUID
median_price_rub: number; median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
range_low_rub: number; range_low_rub: number;
range_high_rub: number; range_high_rub: number;
median_price_per_m2: 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: ConfidenceLevel;
confidence_explanation: string | null; confidence_explanation: string | null;
n_analogs: number; n_analogs: number;