feat(tradein): expected-sold price in estimate PDF (#648 S5b — final) #665
5 changed files with 512 additions and 3 deletions
|
|
@ -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;'>−{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 ────────────────────────────────────────────────────────────
|
# ── 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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)); }
|
||||||
|
|
|
||||||
|
|
@ -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) — реалистичная цена сделки по ДКП Росреестра,
|
||||||
|
// обычно ~на 15–20% ниже 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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue