All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 1m1s
1577 lines
75 KiB
Python
1577 lines
75 KiB
Python
"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera.
|
||
|
||
Дизайн опирается на живой веб-продукт (tradein-mvp/frontend/.../trade-in.css):
|
||
цветовая палитра сконвертирована из OKLCH-токенов веб-приложения в hex через
|
||
точную OKLab→sRGB матрицу (Ottosson), типографика — PT Serif (заголовки страниц,
|
||
только жирное начертание), PT Sans (текст/таблицы) — оба встроены в PDF как
|
||
base64 @font-face (SIL OFL 1.1, см. app/services/exporters/fonts/), с DejaVu
|
||
Serif/Sans как fallback-цепочкой; DejaVu Sans Mono (все числовые значения,
|
||
tabular-nums) остаётся системным — из пакета fonts-dejavu-core, гарантированно
|
||
установленного в Docker-образе. Диапазоны цен (_price_range_chart_svg) — реальный
|
||
matplotlib-график (Agg, SVG), не декоративный CSS-div.
|
||
Документ — деловой/утилитарный отчёт (ближе к банковской оценке, чем к
|
||
маркетинговому лендингу), НЕ 1:1 копия конкурентного референса.
|
||
|
||
Структура отчёта — 4 страницы:
|
||
1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
|
||
2. Listings — рынок квартир-аналогов по объявлениям + таблица примеров
|
||
3. Deals — фактические сделки + таблица примеров
|
||
4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества
|
||
|
||
White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
|
||
Расширения: source badges + дистанция + кликабельные ссылки.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import datetime as dt
|
||
import html as _html
|
||
import io
|
||
import logging
|
||
import pathlib
|
||
import re
|
||
from urllib.parse import urlparse
|
||
from uuid import UUID
|
||
|
||
import matplotlib
|
||
|
||
matplotlib.use("Agg") # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
|
||
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
|
||
import matplotlib.font_manager as _font_manager
|
||
import segno
|
||
from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price_range_chart_svg
|
||
from matplotlib.patches import Rectangle
|
||
|
||
from app.core.config import settings
|
||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── URL allowlist (C-6 security audit) ─────────────────────────────────────
|
||
# Только доверенные домены-источники объявлений попадают в <a href=> в PDF.
|
||
# Защита от javascript: / data: инъекций через source_url объявлений.
|
||
_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset(
|
||
{
|
||
"www.avito.ru",
|
||
"avito.ru",
|
||
"m.avito.ru",
|
||
"www.cian.ru",
|
||
"cian.ru",
|
||
"realty.yandex.ru",
|
||
}
|
||
)
|
||
|
||
# CDN-домены для изображений аналогов (photo_url).
|
||
_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset(
|
||
{
|
||
"images.avito.st",
|
||
"cdn-p.cian.site",
|
||
"avatars.mds.yandex.net",
|
||
}
|
||
)
|
||
|
||
|
||
def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
|
||
"""Проверяет URL по allowlist схемы и домена.
|
||
|
||
cdn=False — allowlist для source_url объявлений (ссылки на листинги).
|
||
cdn=True — allowlist для photo_url (CDN изображений).
|
||
Возвращает None если URL не прошёл проверку — embed/ссылка пропускается.
|
||
"""
|
||
if not url:
|
||
return None
|
||
try:
|
||
p = urlparse(url)
|
||
except Exception:
|
||
return None
|
||
if p.scheme != "https":
|
||
return None
|
||
allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS
|
||
if p.netloc not in allowed:
|
||
return None
|
||
return url
|
||
|
||
|
||
# ── Logo URL и color-sanitation (#13 SSRF / CSS-injection guard) ─────────────
|
||
# Допустимые домены для логотипов брендов (white-label).
|
||
_ALLOWED_LOGO_CDN: frozenset[str] = frozenset(
|
||
{
|
||
"gk-praktika.ru",
|
||
"www.gk-praktika.ru",
|
||
"prinzip.ru",
|
||
"www.prinzip.ru",
|
||
"cdn.gk-praktika.ru",
|
||
}
|
||
)
|
||
|
||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||
|
||
|
||
def _safe_logo_url(url: str | None) -> str | None:
|
||
"""Валидирует logo_url бренда: только https:// + allowlist домен + не data:/file:.
|
||
|
||
data: URI допускаются для инлайн-логотипов (нет сетевого запроса).
|
||
Всё остальное отбрасывается. Если передан file:// — отбрасывается.
|
||
"""
|
||
if not url:
|
||
return None
|
||
try:
|
||
p = urlparse(url)
|
||
except Exception:
|
||
return None
|
||
if p.scheme == "data":
|
||
# data: URI безопасны — нет SSRF, только инлайн
|
||
return url
|
||
if p.scheme != "https":
|
||
# file://, http://, javascript: — все отбрасываем
|
||
return None
|
||
if p.netloc not in _ALLOWED_LOGO_CDN:
|
||
logger.warning("logo_url domain not in allowlist: %s", p.netloc)
|
||
return None
|
||
return url
|
||
|
||
|
||
def _safe_color(color: str | None, fallback: str) -> str:
|
||
"""Принимает только #RRGGBB hex. Всё остальное → fallback.
|
||
|
||
Защита от CSS-injection через primary_color/accent_color бренда
|
||
(WeasyPrint вставляет значение напрямую в style=).
|
||
"""
|
||
if color and _COLOR_RE.match(color):
|
||
return color
|
||
return fallback
|
||
|
||
|
||
# ── WeasyPrint custom url_fetcher (SSRF guard) ───────────────────────────────
|
||
|
||
|
||
def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
|
||
"""Возвращает url_fetcher для WeasyPrint, блокирующий file:// и нелоговые схемы.
|
||
|
||
Разрешены: https:// (логотипы + шрифты CDN) и data: (инлайн SVG/PNG).
|
||
Блокированы: file://, http://, ftp:// и любые другие.
|
||
"""
|
||
|
||
def fetcher(url: str, timeout: int = 10, ssl_context=None): # type: ignore[no-untyped-def]
|
||
# Импорт отложен до момента первого реального fetch, чтобы тесты могли
|
||
# подменять weasyprint.urls через sys.modules без ModuleNotFoundError.
|
||
from weasyprint.urls import default_url_fetcher
|
||
|
||
try:
|
||
p = urlparse(url)
|
||
except Exception:
|
||
raise ValueError(f"Invalid URL in PDF resource: {url!r}") from None
|
||
if p.scheme in ("data", "https"):
|
||
return default_url_fetcher(url, timeout=timeout, ssl_context=ssl_context)
|
||
raise ValueError(
|
||
f"PDF resource scheme '{p.scheme}' is not allowed (only data: and https:). URL: {url!r}"
|
||
)
|
||
|
||
return fetcher
|
||
|
||
|
||
# ── Design tokens (design system) ────────────────────────────────────────────
|
||
# Цвета сконвертированы из OKLCH-токенов живого веб-продукта
|
||
# (tradein-mvp/frontend/src/components/trade-in/trade-in.css) точной OKLab→sRGB
|
||
# матрицей (Ottosson reference), а не приблизительно на глаз. --muted/--success
|
||
# в исходнике веба уже hex — взяты как есть, без конверсии.
|
||
_FG = "#0e1217" # --fg: oklch(18% 0.012 250) — основной текст/чернила
|
||
_BG = "#fbfcfd" # --bg: oklch(99% 0.002 240) — чуть холодный near-white, не чистый #fff
|
||
_MUTED = "#5f6570" # --muted (web, уже hex) — вторичный текст/лейблы
|
||
_ACCENT = "#1b6ad4" # --accent: oklch(54% 0.18 258) — базовый синий (non-white-label)
|
||
_ACCENT_SOFT = "#e6f3ff" # oklch(96% 0.03 258)
|
||
_ACCENT_2 = "#da6600" # --accent-2: oklch(64% 0.17 50) — тёплый вторичный (был generic amber)
|
||
_ACCENT_2_SOFT = "#ffedce" # oklch(96% 0.05 70)
|
||
_DANGER = "#d02b31" # --danger: oklch(56% 0.20 25) — был generic bootstrap-red #dc2626
|
||
_DANGER_SOFT = "#ffe8e4" # oklch(96% 0.04 25)
|
||
_SUCCESS = "#197038" # --success (web, уже hex) — «бесплатно» / экономия
|
||
_BORDER = "#e2e5e8" # --border: oklch(92% 0.005 250) — hairline (был произвольный #e6e8ec)
|
||
_BORDER_STRONG = "#babec3" # --border-strong: oklch(80% 0.008 250)
|
||
_CARD_BG = "#f4f7f9" # --surface-2: oklch(97.5% 0.004 240) — карточка (был #f9fafb)
|
||
|
||
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
|
||
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
||
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
|
||
_FS_SM = "9pt" # labels, заголовки таблиц, explainer-текст
|
||
_FS_MD = "10.5pt" # базовый текст (body), значения в таблицах
|
||
_FS_LG = "13pt" # заголовки страниц (h2, PT Serif)
|
||
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
|
||
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
|
||
|
||
|
||
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
|
||
# Раньше CSS декларировал 'DejaVu Serif'/'DejaVu Sans' — это системные fallback-шрифты
|
||
# без гарантированного визуального качества (только моноширинный 'DejaVu Sans Mono'
|
||
# для цифр остаётся — не часть этой замены, см. _mono()). Теперь настоящие PT Sans/
|
||
# PT Serif встроены как base64 @font-face (см. _build_css) — файлы читаются и
|
||
# кодируются ОДИН раз при импорте модуля, не на каждый рендер PDF.
|
||
_FONTS_DIR = pathlib.Path(__file__).parent / "fonts"
|
||
|
||
|
||
def _font_data_url(filename: str) -> str:
|
||
"""Читает .ttf из fonts/ (рядом с этим модулем) и возвращает data:font/ttf;base64 URI."""
|
||
data = (_FONTS_DIR / filename).read_bytes()
|
||
return f"data:font/ttf;base64,{base64.b64encode(data).decode('ascii')}"
|
||
|
||
|
||
_PT_SANS_REGULAR_URL = _font_data_url("PTSans-Regular.ttf")
|
||
_PT_SANS_BOLD_URL = _font_data_url("PTSans-Bold.ttf")
|
||
_PT_SERIF_BOLD_URL = _font_data_url("PTSerif-Bold.ttf")
|
||
|
||
# Регистрируем те же файлы в matplotlib (chart endpoint-labels используют тот же
|
||
# PT Sans, что и остальной документ — единая типографика, tabular-figure цифры).
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Regular.ttf"))
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Bold.ttf"))
|
||
matplotlib.rcParams["font.family"] = "PT Sans"
|
||
|
||
|
||
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
|
||
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
|
||
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
|
||
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
|
||
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
|
||
# намеренно не конвертируются в наши OKLCH-токены.
|
||
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
|
||
"avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
|
||
"cian": ("#0468ff", "#fff"), # Циан фирменный синий
|
||
"domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер
|
||
"yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный
|
||
"rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий
|
||
"etazhi": ("#e30613", "#fff"), # Этажи красный
|
||
}
|
||
|
||
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
|
||
"avito": "Avito",
|
||
"cian": "Циан",
|
||
"domklik": "Домклик · Сбер",
|
||
"yandex": "Я.Недвижимость",
|
||
"rosreestr": "Росреестр",
|
||
"etazhi": "Этажи",
|
||
}
|
||
|
||
|
||
def _source_logo_pill(source: str) -> str:
|
||
"""Pill-badge с цветом источника."""
|
||
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
|
||
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
|
||
return (
|
||
f"<span style='display:inline-block;padding:3pt 8pt;background:{bg};color:{fg};"
|
||
f"font-size:{_FS_XS};font-weight:700;border-radius:3pt;margin-right:4pt;"
|
||
f"vertical-align:middle;'>{_html.escape(name)}</span>"
|
||
)
|
||
|
||
|
||
def _source_badge_inline(source: str | None) -> str:
|
||
"""Маленький source badge для table cells (без фона)."""
|
||
if not source:
|
||
return f"<span style='color:{_MUTED};'>—</span>"
|
||
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
|
||
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
|
||
return (
|
||
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
|
||
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
|
||
)
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
_NBSP = "\u00a0" # неразрывный пробел — разряды/валюта не переносятся посередине
|
||
|
||
|
||
def _fmt_rub(value: int) -> str:
|
||
"""12 500 000 ₽ — неразрывные пробелы, чтобы число никогда не переносилось
|
||
посередине (разряды/символ валюты не должны сиротеть на разных строках)."""
|
||
return f"{value:,}".replace(",", _NBSP) + _NBSP + "₽"
|
||
|
||
|
||
def _fmt_rub_m(value: int) -> str:
|
||
"""3.4 млн. руб."""
|
||
m = value / 1_000_000
|
||
return f"{m:.1f}".replace(".", ",") + " млн. руб."
|
||
|
||
|
||
def _fmt_ppm2(value: int) -> str:
|
||
"""101 176"""
|
||
return f"{value:,}".replace(",", " ")
|
||
|
||
|
||
def _mono(text: str) -> str:
|
||
"""Оборачивает числовой текст (цены/даты/%/№ отчёта/м²/₽-м²/срок экспозиции)
|
||
в моноширинный span — DejaVu Sans Mono + tabular-nums (см. .mono в _build_css),
|
||
зеркалит web-конвенцию .num/.mono из trade-in.css (HeroSummary.tsx и др.)."""
|
||
return f"<span class='mono'>{text}</span>"
|
||
|
||
|
||
def _report_number(estimate_id: UUID) -> str:
|
||
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
|
||
short = int(estimate_id.int) % 10_000
|
||
long = int(estimate_id.int) % 10_000_000_000
|
||
return f"EKБ-{short:04d}-{long:010d}"
|
||
|
||
|
||
def _qr_code_data_url(text: str, size: int = 4) -> str:
|
||
"""QR-код как SVG data URL."""
|
||
qr = segno.make(text, error="m")
|
||
buf = io.BytesIO()
|
||
qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
|
||
return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
|
||
|
||
|
||
def _conf_label(confidence: str) -> str:
|
||
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
|
||
|
||
|
||
# ── Unified page header (cover + pages 2-4 использовали визуально разные
|
||
# паттерны — сплошная цветная плашка vs текстовая полоска; теперь один блок) ──
|
||
|
||
|
||
def _page_header(brand) -> str: # type: ignore[no-untyped-def]
|
||
"""Логотип/имя бренда + акцентная линия снизу — единый паттерн для всех 4 страниц.
|
||
|
||
brand.primary_color по-прежнему управляет цветом линии/плашки (white-label
|
||
механизм не тронут) — только markup унифицирован.
|
||
"""
|
||
logo_url = _safe_logo_url(brand.logo_url)
|
||
mark_html = (
|
||
f"<img src='{_html.escape(logo_url)}' alt='{_html.escape(brand.name)}' "
|
||
f"style='max-height:20pt;max-width:110pt;object-fit:contain;vertical-align:middle;'/>"
|
||
if logo_url
|
||
else f"<span style='font-size:{_FS_LG};font-weight:700;letter-spacing:0.06em;"
|
||
f"color:{brand.primary_color};'>{_html.escape(brand.name).upper()}</span>"
|
||
)
|
||
return (
|
||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
||
f"border-bottom:2pt solid {brand.primary_color};padding-bottom:4pt;"
|
||
f'margin-bottom:8pt;">'
|
||
f"{mark_html}"
|
||
f'<span style="font-size:{_FS_XS};color:{_MUTED};letter-spacing:0.04em;'
|
||
f'text-transform:uppercase;">Trade-In отчёт</span>'
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
# ── Price range chart — matplotlib SVG bullet/range chart (real chart, не div) ──
|
||
# Раньше рендерилась одним div с фиксированной 64%-заливкой/14%-отступом — числа
|
||
# НЕ определяли позицию заливки (чисто декоративный "прогресс-бар", не привязанный
|
||
# к range_low/range_high). Теперь — настоящий график: серый трек на всю область
|
||
# построения + акцентная заливка ровно на [range_low, range_high] (в млн ₽) + подписи-
|
||
# концы (tabular figures, тот же PT Sans, что и весь документ) + опционально срок
|
||
# экспозиции. Рендерится headless (Agg backend, см. import matplotlib.use выше) в
|
||
# SVG и встраивается как base64 <img> — WeasyPrint рендерит SVG только через image
|
||
# pipeline (не как inline-разметку), см. reference prototype build_report.py.
|
||
|
||
|
||
def _price_range_chart_svg(
|
||
range_low: int,
|
||
range_high: int,
|
||
*,
|
||
label_left: str | None = None,
|
||
label_right: str | None = None,
|
||
sub_label: str = "Рынок",
|
||
days_min: int | None = None,
|
||
days_max: int | None = None,
|
||
show_days: bool = False,
|
||
accent: str = _ACCENT,
|
||
) -> str:
|
||
"""Bullet/range-chart диапазона цен (matplotlib Agg → SVG → base64 <img>).
|
||
|
||
Подложка-трек (светло-серый) + закрашенная акцентная зона на реальном диапазоне
|
||
[range_low, range_high] + акцентные end-tick'и + подписи-концы + опционально
|
||
срок экспозиции (show_days=True) — та же информация, что раньше показывал
|
||
_price_range_bar (удалена), но настоящий график, а не декоративный div."""
|
||
if range_high <= range_low:
|
||
return ""
|
||
|
||
label_left = label_left or _fmt_rub_m(range_low)
|
||
label_right = label_right or _fmt_rub_m(range_high)
|
||
|
||
lo_m = range_low / 1_000_000
|
||
hi_m = range_high / 1_000_000
|
||
pad = (hi_m - lo_m) * 0.08 or 0.3
|
||
|
||
y = 0.5
|
||
track_h = 0.34
|
||
|
||
# Высота figure (points) задаёт только масштаб track/pad — реальная высота
|
||
# итогового SVG определяется bbox_inches="tight" (см. savefig ниже) по фактическому
|
||
# содержимому, включая подписи ниже track. Подписи — через ax.annotate с
|
||
# textcoords="offset points": абсолютный точечный отступ, НЕ зависящий от масштаба
|
||
# data-координат/figsize (в отличие от прежней data-fraction схемы, которая на
|
||
# маленьком figsize схлопывала строки label/days друг в друга).
|
||
# Figure() object API (НЕ pyplot.subplots) — write_pdf() выполняется в FastAPI
|
||
# threadpool-executor (sync endpoint), где несколько запросов на PDF могут рендерить
|
||
# график конкурентно; pyplot держит глобальный figure-manager, не потокобезопасный
|
||
# для такого сценария (см. matplotlib FAQ "Embedding in a web application server").
|
||
# Figure() не регистрируется нигде глобально — никакой утечки, plt.close() не нужен.
|
||
fig = Figure(figsize=(6.85, 28 / 72), dpi=200)
|
||
ax = fig.subplots()
|
||
fig.patch.set_alpha(0)
|
||
ax.set_facecolor("none")
|
||
# Axes растянуты на всю figure — предсказуемая точка отсчёта для track_bottom.
|
||
ax.set_position((0, 0, 1, 1))
|
||
|
||
track_bottom = y - track_h / 2
|
||
|
||
# подложка-трек — на всю область построения (визуальный "инструмент")
|
||
ax.add_patch(
|
||
Rectangle(
|
||
(lo_m - pad, track_bottom),
|
||
(hi_m - lo_m) + 2 * pad,
|
||
track_h,
|
||
facecolor=_BORDER,
|
||
edgecolor="none",
|
||
zorder=1,
|
||
)
|
||
)
|
||
# закрашенная акцентная зона — реальный диапазон range_low..range_high
|
||
ax.add_patch(
|
||
Rectangle(
|
||
(lo_m, track_bottom),
|
||
hi_m - lo_m,
|
||
track_h,
|
||
facecolor=accent,
|
||
edgecolor="none",
|
||
zorder=2,
|
||
)
|
||
)
|
||
# end-tick'и по границам активной зоны (bullet-chart акцент)
|
||
for x in (lo_m, hi_m):
|
||
ax.plot(
|
||
[x, x],
|
||
[track_bottom - 0.04, y + track_h / 2 + 0.04],
|
||
color=accent,
|
||
linewidth=2.2,
|
||
solid_capstyle="round",
|
||
zorder=3,
|
||
)
|
||
|
||
# Размер подписей выровнен с типографской шкалой документа (_FS_MD/_FS_XS) —
|
||
# тот же масштаб, что раньше показывал div-бар.
|
||
for x, label, ha in ((lo_m, label_left, "left"), (hi_m, label_right, "right")):
|
||
ax.annotate(
|
||
label,
|
||
xy=(x, track_bottom),
|
||
xycoords="data",
|
||
xytext=(0, -4),
|
||
textcoords="offset points",
|
||
color=_FG,
|
||
fontsize=10.5,
|
||
ha=ha,
|
||
va="top",
|
||
fontweight="bold",
|
||
annotation_clip=False,
|
||
)
|
||
|
||
if show_days and days_min is not None and days_max is not None:
|
||
for x, days, ha in ((lo_m, days_min, "left"), (hi_m, days_max, "right")):
|
||
ax.annotate(
|
||
f"{days} дней",
|
||
xy=(x, track_bottom),
|
||
xycoords="data",
|
||
xytext=(0, -19),
|
||
textcoords="offset points",
|
||
color=_DANGER,
|
||
fontsize=8,
|
||
ha=ha,
|
||
va="top",
|
||
fontweight="bold",
|
||
annotation_clip=False,
|
||
)
|
||
|
||
ax.set_xlim(lo_m - pad, hi_m + pad)
|
||
ax.set_ylim(0, 1)
|
||
ax.axis("off")
|
||
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format="svg", transparent=True, bbox_inches="tight", pad_inches=0.03)
|
||
chart_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||
|
||
return (
|
||
f'<div style="margin:3pt 0;">'
|
||
f'<div style="text-align:center;font-size:{_FS_SM};color:{_MUTED};'
|
||
f'margin-bottom:3pt;">{_html.escape(sub_label)}</div>'
|
||
f'<img style="width:100%;display:block;" alt="{_html.escape(sub_label)}" '
|
||
f'src="data:image/svg+xml;base64,{chart_b64}">'
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
# ── 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%».
|
||
Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono)."""
|
||
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:{_FS_XS};font-weight:700;"
|
||
f"border-radius:8pt;vertical-align:middle;'>{_mono(f'−{delta_pct}%')}</span>"
|
||
)
|
||
range_html = ""
|
||
if range_low is not None and range_high is not None and range_high > range_low:
|
||
range_text = (
|
||
f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}"
|
||
)
|
||
range_html = (
|
||
f"<div style='font-size:{_FS_MD};color:{_MUTED};font-weight:600;margin-top:3pt;'>"
|
||
f"{_mono(range_text)}</div>"
|
||
)
|
||
perm2_html = ""
|
||
if per_m2 is not None and per_m2 > 0:
|
||
perm2_html = (
|
||
f"<div style='font-size:{_FS_SM};color:{_MUTED};margin-top:2pt;'>"
|
||
f"{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}</div>"
|
||
)
|
||
return (
|
||
f"<td style='width:50%;vertical-align:top;padding:7pt 10pt;background:{_CARD_BG};"
|
||
f"border-top:2pt solid {accent};border-radius:4pt;'>"
|
||
f"<div style='font-size:{_FS_SM};color:{_MUTED};font-weight:700;text-transform:uppercase;"
|
||
f"letter-spacing:0.03em;'>{_html.escape(label)}{delta_chip}</div>"
|
||
f"<div style='font-size:{_FS_XXL};font-weight:900;color:{_FG};margin-top:4pt;'>"
|
||
f"{_mono(_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=_DANGER,
|
||
delta_pct=_discount_pct(estimate),
|
||
)
|
||
|
||
explainer = (
|
||
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
|
||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
|
||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||
"Росреестра, обычно ниже запроса."
|
||
"</p>"
|
||
)
|
||
|
||
return (
|
||
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
||
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||
f"{explainer}"
|
||
)
|
||
|
||
|
||
# ── Page 1: Cover ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
today = dt.date.today()
|
||
# Используем реальное поле expires_at из оценки; fallback на +30 дней если None.
|
||
expires = (
|
||
estimate.expires_at.date()
|
||
if estimate.expires_at is not None
|
||
else today + dt.timedelta(days=30)
|
||
)
|
||
report_num = _report_number(estimate.estimate_id)
|
||
|
||
# Короткий адрес (для cover): берём первую часть до запятой
|
||
full_address = input_snapshot.get("address", "—")
|
||
address_short = full_address.split(",")[0:3]
|
||
address_short = ", ".join(s.strip() for s in address_short)
|
||
address = _html.escape(address_short or full_address)
|
||
area = input_snapshot.get("area_m2", 0)
|
||
rooms = input_snapshot.get("rooms", 0)
|
||
floor = input_snapshot.get("floor") or "—"
|
||
total_floors = input_snapshot.get("total_floors") or "—"
|
||
year_built = input_snapshot.get("year_built", "—")
|
||
house_type = input_snapshot.get("house_type")
|
||
repair_state = input_snapshot.get("repair_state")
|
||
has_balcony = input_snapshot.get("has_balcony")
|
||
|
||
house_labels = {
|
||
"panel": "Панельный",
|
||
"brick": "Кирпичный",
|
||
"monolith": "Монолитный",
|
||
"monolith_brick": "Монолит-кирпич",
|
||
"other": "Другое",
|
||
}
|
||
repair_labels = {
|
||
"needs_repair": "Требуется ремонт",
|
||
"standard": "Стандартный",
|
||
"good": "Хороший",
|
||
"excellent": "Евроремонт",
|
||
}
|
||
|
||
rooms_label = (
|
||
"Студия"
|
||
if rooms == 0
|
||
else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "")
|
||
)
|
||
house_label = house_labels.get(house_type, "—") if house_type else "—"
|
||
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
||
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
|
||
|
||
# Active market subband — 4-118 days range (fallback без данных).
|
||
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
|
||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||
|
||
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
||
deals_low, deals_high = _deals_range(
|
||
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
|
||
)
|
||
|
||
listings_bar = _price_range_chart_svg(
|
||
estimate.range_low_rub,
|
||
estimate.range_high_rub,
|
||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||
days_min=days_min,
|
||
days_max=days_max,
|
||
show_days=True,
|
||
)
|
||
|
||
deals_bar = _price_range_chart_svg(
|
||
deals_low,
|
||
deals_high,
|
||
sub_label="Диапазон цен по фактическим сделкам",
|
||
)
|
||
|
||
disclaimer_html = ""
|
||
if brand.pdf_disclaimer:
|
||
disclaimer_html = (
|
||
f"<p style='margin-top:10pt;font-size:{_FS_XS};color:{_MUTED};line-height:1.4;"
|
||
f"border-top:1pt solid {_BORDER};padding-top:6pt;'>"
|
||
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
||
)
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand)}
|
||
|
||
<h1 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_XL};font-weight:700;
|
||
margin:3pt 0 6pt 0;text-transform:uppercase;letter-spacing:0.02em;line-height:1.2;">
|
||
АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
|
||
</h1>
|
||
|
||
<!-- Параметры квартиры -->
|
||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
|
||
<tr><td>№ отчета</td><td class="bold mono">{_mono(report_num)}</td></tr>
|
||
<tr><td>Дата отчета</td><td class="bold">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
||
<tr><td>Срок действия данных</td>
|
||
<td class="bold">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||
<tr><td>Адрес</td><td class="bold">{address}</td></tr>
|
||
<tr><td>Год постройки</td><td class="bold">{_mono(str(year_built))}</td></tr>
|
||
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
|
||
<tr><td>Этаж / этажность</td><td class="bold">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
||
<tr><td>Площадь</td><td class="bold">{_mono(f"{area} м²")}</td></tr>
|
||
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
|
||
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
|
||
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
|
||
</table>
|
||
|
||
<!-- Две равновесные цены: ориентир запроса vs ожидаемая цена продажи (#648 S5b) -->
|
||
{_dual_price_block(estimate, brand)}
|
||
|
||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен в объявлениях
|
||
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
||
</p>
|
||
{listings_bar}
|
||
|
||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен по фактическим сделкам
|
||
</p>
|
||
{deals_bar}
|
||
|
||
<!-- Что важно при оценке -->
|
||
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
|
||
Что важно при оценке стоимости квартиры:
|
||
</h3>
|
||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||
<tr>
|
||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
||
Росреестр, ДомКлик и продажи агентств недвижимости</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
||
<td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
|
||
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
|
||
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
|
||
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_BORDER};
|
||
padding-top:4pt;">
|
||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||
</p>
|
||
{disclaimer_html}
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
|
||
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
|
||
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||
if not days:
|
||
return 4, 118
|
||
return min(days), max(days)
|
||
|
||
|
||
def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]:
|
||
"""Min/max price от deals. Fallback к listings range."""
|
||
if not deals:
|
||
return fallback
|
||
prices = [d.price_rub for d in deals]
|
||
return min(prices), max(prices)
|
||
|
||
|
||
# ── Page 2: Listings (market) ────────────────────────────────────────────────
|
||
|
||
|
||
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
n_total = estimate.n_analogs
|
||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||
# count «с учётом ремонта» не существует, второе число было идентично n_total.
|
||
|
||
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
|
||
sources_to_show = estimate.sources_used or []
|
||
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
|
||
|
||
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
|
||
area = float(input_snapshot.get("area_m2", 0) or 0)
|
||
area_min = round(area * 0.85, 1)
|
||
area_max = round(area * 1.15, 1)
|
||
rooms = input_snapshot.get("rooms", 0)
|
||
year_built = input_snapshot.get("year_built")
|
||
year_range = f"{year_built - 2}-{year_built + 2}" if year_built else "—"
|
||
house_type = input_snapshot.get("house_type")
|
||
house_label = (
|
||
{
|
||
"panel": "Эконом до 2000 года",
|
||
"brick": "Кирпич до 2000",
|
||
"monolith": "Современный (монолит)",
|
||
"monolith_brick": "Современный (м-к)",
|
||
"other": "Любой",
|
||
}.get(house_type, "Любой")
|
||
if house_type
|
||
else "Любой"
|
||
)
|
||
repair_state = input_snapshot.get("repair_state")
|
||
repair_label = (
|
||
{
|
||
"needs_repair": "Требуется ремонт",
|
||
"standard": "Стандартный",
|
||
"good": "Хороший",
|
||
"excellent": "Евроремонт",
|
||
}.get(repair_state, "Любой")
|
||
if repair_state
|
||
else "Любой"
|
||
)
|
||
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||
|
||
# Полоска диапазона
|
||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||
range_bar = _price_range_chart_svg(
|
||
estimate.range_low_rub,
|
||
estimate.range_high_rub,
|
||
sub_label="Рынок",
|
||
days_min=days_min,
|
||
days_max=days_max,
|
||
show_days=True,
|
||
)
|
||
|
||
# Топ-5 примеров (отсортированных по distance)
|
||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||
examples_rows = _examples_rows(top5)
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand)}
|
||
|
||
<h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_LG};font-weight:700;
|
||
text-transform:uppercase;letter-spacing:0.02em;border-bottom:1.5pt solid {_BORDER};
|
||
padding-bottom:4pt;margin-bottom:12pt;">
|
||
РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
|
||
</h2>
|
||
|
||
<!-- Два колонки: левая (counts + источники), правая (params) -->
|
||
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
||
<tr>
|
||
<td style="width:55%;vertical-align:top;padding-right:14pt;">
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
|
||
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
|
||
</table>
|
||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">Источники данных</div>
|
||
<div style="margin-top:6pt;">{sources_html}</div>
|
||
</td>
|
||
<td style="width:45%;vertical-align:top;background:{_CARD_BG};padding:10pt;
|
||
border-radius:4pt;">
|
||
<table class="search-params" style="width:100%;border-collapse:collapse;">
|
||
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
|
||
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
|
||
<tr><td>Год постройки</td><td class="bold">{_mono(year_range)}</td></tr>
|
||
<tr><td>Диапазон площади</td>
|
||
<td class="bold">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
|
||
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
|
||
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен в объявлениях</p>
|
||
{range_bar}
|
||
|
||
<!-- Примеры аналогов -->
|
||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||
Примеры аналогичных объектов с учётом ремонта
|
||
</h3>
|
||
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
|
||
<thead>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Адрес квартиры</th>
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Источник</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость 1 м², руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость объекта, руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Срок экспозиции, дней</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>{examples_rows}</tbody>
|
||
</table>
|
||
|
||
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid {_BORDER};
|
||
font-size:{_FS_XS};color:{_MUTED};">
|
||
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2
|
||
</div>
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
def _examples_rows(lots: list[AnalogLot]) -> str:
|
||
if not lots:
|
||
return (
|
||
"<tr><td colspan='5' class='empty' "
|
||
f"style='text-align:center;padding:14pt;color:{_MUTED};'>Нет данных</td></tr>"
|
||
)
|
||
rows = []
|
||
for lot in lots:
|
||
addr = _html.escape(lot.address)
|
||
safe = _safe_url(lot.source_url)
|
||
if safe:
|
||
addr_cell = (
|
||
f"<a href='{_html.escape(safe)}' "
|
||
f"style='color:{_ACCENT};text-decoration:none;'>{addr} ↗</a>"
|
||
)
|
||
else:
|
||
addr_cell = addr
|
||
ppm2_cell = _fmt_ppm2(lot.price_per_m2) if lot.price_per_m2 is not None else "—"
|
||
price_cell = _fmt_ppm2(lot.price_rub) if lot.price_rub is not None else "—"
|
||
# #1533: 0 — валидное значение (лот размещён сегодня), `0 or '—'` теряло бы его.
|
||
days_cell = lot.days_on_market if lot.days_on_market is not None else "—"
|
||
rows.append(
|
||
"<tr>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};'>{addr_cell}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};'>"
|
||
f"{_source_badge_inline(lot.source)}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;'>"
|
||
f"{_mono(str(ppm2_cell))}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;'>"
|
||
f"{_mono(str(price_cell))}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;'>"
|
||
f"{_mono(str(days_cell))}</td>"
|
||
"</tr>"
|
||
)
|
||
return "".join(rows)
|
||
|
||
|
||
# ── Page 3: Deals ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
n_deals = len(estimate.actual_deals)
|
||
today = dt.date.today()
|
||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||
|
||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
|
||
if not deal_sources:
|
||
deal_sources = [s for s in (estimate.sources_used or [])]
|
||
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
|
||
|
||
area = float(input_snapshot.get("area_m2", 0) or 0)
|
||
area_min = round(area * 0.85, 1)
|
||
area_max = round(area * 1.15, 1)
|
||
rooms = input_snapshot.get("rooms", 0)
|
||
year_built = input_snapshot.get("year_built")
|
||
year_range = f"{year_built - 2}–{year_built + 2}" if year_built else "—"
|
||
house_type = input_snapshot.get("house_type")
|
||
# #1532: маппинг идентичен странице «Листинги» (включая monolith_brick/other),
|
||
# иначе один и тот же «Тип дома» рассинхронизирован между стр.2 и стр.3.
|
||
house_label = (
|
||
{
|
||
"panel": "Эконом до 2000 года",
|
||
"brick": "Кирпич до 2000",
|
||
"monolith": "Современный (монолит)",
|
||
"monolith_brick": "Современный (м-к)",
|
||
"other": "Любой",
|
||
}.get(house_type, "Любой")
|
||
if house_type
|
||
else "Любой"
|
||
)
|
||
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||
|
||
deals_low, deals_high = _deals_range(
|
||
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
|
||
)
|
||
# #1366: НЕ показываем срок экспозиции для бара сделок. actual_deals — это
|
||
# зарегистрированные ДКП Росреестра без days_on_market (поле None), поэтому
|
||
# _days_on_market_range вернул бы хардкод-fallback (4, 118) — выдуманные сроки
|
||
# под заголовком «Диапазон цен по фактическим сделкам». Cover-страница так же
|
||
# намеренно не рисует days для бара сделок (show_days по умолчанию False).
|
||
range_bar = _price_range_chart_svg(
|
||
deals_low,
|
||
deals_high,
|
||
sub_label="Рынок",
|
||
)
|
||
|
||
top5 = estimate.actual_deals[:5]
|
||
examples_rows = _examples_rows(top5)
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand)}
|
||
|
||
<h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_LG};font-weight:700;
|
||
text-transform:uppercase;letter-spacing:0.02em;border-bottom:1.5pt solid {_BORDER};
|
||
padding-bottom:4pt;margin-bottom:12pt;">
|
||
ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ
|
||
</h2>
|
||
|
||
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
||
<tr>
|
||
<td style="width:55%;vertical-align:top;padding-right:14pt;">
|
||
<table style="width:100%;border-collapse:collapse;">
|
||
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
||
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||
<tr><td style="padding:4pt 0;">Период сделок</td>
|
||
<td class="bold" style="text-align:right;">
|
||
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
|
||
</table>
|
||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">Источники данных</div>
|
||
<div style="margin-top:6pt;">{sources_html}</div>
|
||
</td>
|
||
<td style="width:45%;vertical-align:top;background:{_CARD_BG};padding:10pt;
|
||
border-radius:4pt;">
|
||
<table class="search-params" style="width:100%;border-collapse:collapse;">
|
||
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
|
||
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
|
||
<tr><td>Год постройки</td><td class="bold">{_mono(year_range)}</td></tr>
|
||
<tr><td>Диапазон площади</td>
|
||
<td class="bold">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
|
||
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен по фактическим сделкам</p>
|
||
{range_bar}
|
||
|
||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
||
чем заявлено в объявлениях (Екатеринбург, 2026)
|
||
</div>
|
||
|
||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||
Примеры аналогичных объектов
|
||
</h3>
|
||
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
|
||
<thead>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Адрес квартиры</th>
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Источник</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость 1 м², руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость объекта, руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Срок экспозиции, дней</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>{examples_rows}</tbody>
|
||
</table>
|
||
|
||
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid {_BORDER};
|
||
font-size:{_FS_XS};color:{_MUTED};">
|
||
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3
|
||
</div>
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
||
|
||
|
||
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
|
||
median = estimate.median_price_rub
|
||
|
||
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
|
||
# часть — фиксированные диапазоны рынка ЕКБ (риелтор/аренда/юрист/реклама).
|
||
listing_price = median # цена в объявлении
|
||
torg_pct_low, torg_pct_high = 5, 15
|
||
torg_low = int(listing_price * torg_pct_low / 100)
|
||
torg_high = int(listing_price * torg_pct_high / 100)
|
||
|
||
# Data-consistency fix: sold_price теперь зеркалит cover (_dual_price_block) — берём
|
||
# estimate.expected_sold_price_rub (та же реальная цифра по ДКП Росреестра,
|
||
# что на cover в «Ожидаемая цена продажи»), а не независимый плоский 10%
|
||
# хайркат от медианы. Раньше стр.1 и стр.4 одного и того же PDF могли
|
||
# показывать разные цифры (расхождение 10%+) — тот же класс бага, что и
|
||
# _discount_pct/_dual_price_block graceful fallback ниже.
|
||
sold = estimate.expected_sold_price_rub
|
||
if sold is not None and sold > 0:
|
||
sold_price = sold
|
||
else:
|
||
# Fallback только для старых оценок без sold-данных (None/<=0).
|
||
sold_price = listing_price - int(listing_price * 0.10)
|
||
|
||
rieltor_pct_low, rieltor_pct_high = 2, 5
|
||
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
||
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
||
|
||
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
|
||
rent_low, rent_high = 84_000, 135_000
|
||
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
||
juridical_low, juridical_high = 15_000, 50_000
|
||
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
||
# ЕКБ-агрегированного источника), см. sub-caption "ориентировочно" в таблице.
|
||
ads_low, ads_high = 6_000, 45_000
|
||
|
||
total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
|
||
total_high = torg_high + rieltor_high + rent_high + juridical_high + ads_high
|
||
|
||
brand_short = _html.escape(brand.name)
|
||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
||
|
||
return f"""
|
||
<div><!-- last page: NO page-break-after -->
|
||
|
||
{_page_header(brand)}
|
||
|
||
<h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_LG};font-weight:700;
|
||
text-transform:uppercase;letter-spacing:0.02em;border-bottom:1.5pt solid {_BORDER};
|
||
padding-bottom:4pt;margin-bottom:14pt;">
|
||
ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
|
||
</h2>
|
||
|
||
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
||
Структура стоимости сделки</h3>
|
||
|
||
<table class="offer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">
|
||
<colgroup><col style="width:42%;"><col style="width:16%;"><col style="width:42%;"></colgroup>
|
||
<thead>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<th style="text-align:left;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;"></th>
|
||
<th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">
|
||
{trade_in_label}
|
||
</th>
|
||
<th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">
|
||
Самостоятельная продажа, руб.
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Торг потенциальных покупателей</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">при стоимости квартиры в объявлении
|
||
{_mono(_fmt_rub(listing_price))}</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_MUTED};">Не применимо</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;color:{_DANGER};">
|
||
от {_mono(f"{_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
|
||
{_mono(f"от {torg_pct_low}–{torg_pct_high}%")}</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Услуги риэлтора при продаже квартиры</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">с минимальным торгом за
|
||
{_mono(_fmt_rub(sold_price))}</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
{_mono(f"{_fmt_rub(rieltor_low)} – {_fmt_rub(rieltor_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
|
||
{_mono(f"от {rieltor_pct_low}–{rieltor_pct_high}%")}</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Аренда после сделки</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
{_mono(f"{_fmt_rub(rent_low)} – {_fmt_rub(rent_high)}")}
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Юридическое сопровождение</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">Проверка документов и подготовка
|
||
договоров</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
от {_mono(f"{_fmt_rub(juridical_low)} – {_fmt_rub(juridical_high)}")}</td>
|
||
</tr>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Расходы на рекламу</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
|
||
на Циан, Авито, Я.Недвижимости</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
от {_mono(f"{_fmt_rub(ads_low)} – {_fmt_rub(ads_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;font-style:italic;">
|
||
за 3 месяца, ориентировочно</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="background:{_DANGER_SOFT};">
|
||
<td style="padding:8pt 4pt;font-size:{_FS_LG};font-weight:700;">
|
||
Общие финансовые потери</td>
|
||
<td style="padding:8pt 4pt;text-align:right;"></td>
|
||
<td style="padding:8pt 4pt;text-align:right;font-size:{_FS_LG};font-weight:700;
|
||
color:{_DANGER};">
|
||
от {_mono(f"{_fmt_rub(total_low)} – {_fmt_rub(total_high)}")}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
|
||
самостоятельной продаже квартиры
|
||
</div>
|
||
|
||
<h3 style="margin-top:18pt;font-size:{_FS_MD};font-weight:700;">Преимущества</h3>
|
||
<div class="advantages" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;
|
||
gap:8pt;margin-top:8pt;">
|
||
<div class="advantage-item">
|
||
<div class="adv-ic">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{_ACCENT_2}"
|
||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/>
|
||
<path d="m15.5 7.5 3 3L22 7l-3-3"/>
|
||
</svg>
|
||
</div>
|
||
<div class="adv-title">Экономия времени</div>
|
||
<div class="adv-desc">Берём на себя показы, переговоры и поиск покупателей</div>
|
||
</div>
|
||
<div class="advantage-item">
|
||
<div class="adv-ic">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{_ACCENT_2}"
|
||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||
</svg>
|
||
</div>
|
||
<div class="adv-title">Юридическая безопасность</div>
|
||
<div class="adv-desc">Проверяем документы, исключаем риски, сопровождаем на всех этапах</div>
|
||
</div>
|
||
<div class="advantage-item">
|
||
<div class="adv-ic">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{_ACCENT_2}"
|
||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/>
|
||
<circle cx="17.5" cy="17.5" r="2.5"/>
|
||
</svg>
|
||
</div>
|
||
<div class="adv-title">Фиксированная стоимость новостройки</div>
|
||
<div class="adv-desc">Сохраняем цену выбранной планировки в {brand_short}</div>
|
||
</div>
|
||
<div class="advantage-item">
|
||
<div class="adv-ic">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="{_ACCENT_2}"
|
||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/>
|
||
<circle cx="12" cy="12" r="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="adv-title">Гарантия цены</div>
|
||
<div class="adv-desc">Снимаем угрозу колебаний рынка и удерживаем договорную стоимость</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid {_BORDER};
|
||
font-size:{_FS_XS};color:{_MUTED};">
|
||
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 4 ·
|
||
Расчёт носит ориентировочный характер и не является офертой.
|
||
</div>
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── CSS ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
||
primary = brand.primary_color if brand else "#1d4ed8"
|
||
return f"""
|
||
@font-face {{
|
||
font-family: 'PT Sans';
|
||
src: url({_PT_SANS_REGULAR_URL}) format('truetype');
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'PT Sans';
|
||
src: url({_PT_SANS_BOLD_URL}) format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'PT Serif';
|
||
src: url({_PT_SERIF_BOLD_URL}) format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
}}
|
||
@page {{
|
||
size: A4;
|
||
margin: 20mm 18mm 20mm 18mm;
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{
|
||
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
||
font-size: {_FS_MD};
|
||
color: {_FG};
|
||
background: {_BG};
|
||
margin: 0;
|
||
padding: 0;
|
||
line-height: 1.35;
|
||
}}
|
||
h1, h2, h3 {{ margin: 0; }}
|
||
h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
|
||
.page {{ page-break-after: always; position: relative; }}
|
||
.page:last-child {{ page-break-after: avoid; }}
|
||
|
||
.bold {{ font-weight: 700; }}
|
||
.mono, .mono * {{
|
||
font-family: 'DejaVu Sans Mono', monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
}}
|
||
|
||
/* Params table (Cover) */
|
||
.params-table td {{
|
||
padding: 2pt 4pt;
|
||
border-bottom: 1px solid {_BORDER};
|
||
font-size: {_FS_SM};
|
||
}}
|
||
.params-table td:first-child {{
|
||
color: {_MUTED};
|
||
width: 40%;
|
||
}}
|
||
.params-table td:last-child {{
|
||
text-align: right;
|
||
}}
|
||
|
||
/* Search params (Listings/Deals) */
|
||
.search-params td {{
|
||
padding: 4pt 0;
|
||
font-size: {_FS_SM};
|
||
}}
|
||
.search-params td:first-child {{
|
||
color: {_MUTED};
|
||
}}
|
||
.search-params td:last-child {{
|
||
text-align: right;
|
||
}}
|
||
|
||
/* Что важно — advice table */
|
||
.advice-table td {{
|
||
padding: 3.5pt 6pt;
|
||
border-bottom: 1px solid {_BORDER};
|
||
vertical-align: top;
|
||
font-size: {_FS_SM};
|
||
line-height: 1.3;
|
||
}}
|
||
.advice-title {{
|
||
font-weight: 700;
|
||
width: 30%;
|
||
color: {_FG};
|
||
}}
|
||
.advice-text {{
|
||
color: {_MUTED};
|
||
}}
|
||
|
||
/* Advantages (Offer) — тёплый accent-2 вместо generic red-tinted кругов */
|
||
.advantage-item {{
|
||
border: 1px solid {_BORDER};
|
||
border-radius: 4pt;
|
||
padding: 8pt;
|
||
text-align: left;
|
||
}}
|
||
.advantage-item .adv-ic {{
|
||
display: block;
|
||
width: 28pt;
|
||
height: 28pt;
|
||
border-radius: 50%;
|
||
background: {_ACCENT_2_SOFT};
|
||
margin: 0 0 6pt 0;
|
||
line-height: 28pt;
|
||
text-align: center;
|
||
}}
|
||
.advantage-item .adv-ic svg {{
|
||
vertical-align: middle;
|
||
}}
|
||
.advantage-item .adv-title {{
|
||
font-weight: 700;
|
||
font-size: {_FS_XS};
|
||
margin-bottom: 3pt;
|
||
color: {primary};
|
||
letter-spacing: -0.01em;
|
||
}}
|
||
.advantage-item .adv-desc {{
|
||
font-size: {_FS_XS};
|
||
color: {_MUTED};
|
||
line-height: 1.3;
|
||
}}
|
||
"""
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
"""Страница-заглушка для случаев, когда данных недостаточно (#697/#9).
|
||
|
||
Рендерится вместо числового отчёта при estimate.insufficient_data=True.
|
||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||
"""
|
||
address = _html.escape(input_snapshot.get("address", "—"))
|
||
report_num = _report_number(estimate.estimate_id)
|
||
today = dt.date.today()
|
||
return f"""
|
||
<div>
|
||
{_page_header(brand)}
|
||
<p style="font-size:{_FS_SM};color:{_MUTED};margin:0 0 18pt 0;">
|
||
№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}
|
||
</p>
|
||
<div style="margin:40pt auto;text-align:center;max-width:320pt;">
|
||
<div style="font-size:32pt;margin-bottom:12pt;color:{_MUTED};">—</div>
|
||
<h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_XL};font-weight:700;
|
||
color:{_FG};margin-bottom:8pt;">
|
||
Недостаточно данных для оценки
|
||
</h2>
|
||
<p style="font-size:{_FS_MD};color:{_MUTED};line-height:1.5;">
|
||
По данному объекту не найдено достаточного количества аналогов
|
||
для формирования надёжной рыночной оценки.
|
||
Пожалуйста, уточните параметры или обратитесь к специалисту.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
|
||
def generate_trade_in_pdf(
|
||
estimate: AggregatedEstimate,
|
||
input_snapshot: dict, # type: ignore[type-arg]
|
||
*,
|
||
brand=None, # type: ignore[no-untyped-def]
|
||
) -> bytes:
|
||
"""Генерирует WeasyPrint PDF отчёт Trade-In (собственная визуальная система).
|
||
|
||
Если estimate.insufficient_data=True — рендерит одностраничный empty-state
|
||
вместо числового отчёта («Недостаточно данных», #697/#9).
|
||
|
||
Pages (normal):
|
||
1. Cover — № отчёта + параметры + 2 диапазона + 3 блока «Что важно»
|
||
2. Listings — рынок аналогов + источники + таблица примеров
|
||
3. Deals — фактические сделки + источники + таблица
|
||
4. Offer — Trade-In vs самопродажа + 4 преимущества
|
||
|
||
Args:
|
||
estimate: AggregatedEstimate из БД
|
||
input_snapshot: ввод пользователя (address, area_m2, ...)
|
||
brand: optional Brand для white-label
|
||
|
||
Returns:
|
||
PDF bytes для Response(media_type="application/pdf")
|
||
"""
|
||
# WeasyPrint импортируется лениво: его native-зависимости (pango/cairo) нужны
|
||
# только для финального write_pdf — HTML-билдеры (cover/listings/...) работают
|
||
# без них, что делает рендер-функции тестируемыми в окружениях без native libs.
|
||
# FontConfiguration берём через атрибут (не отдельный `from weasyprint.text.fonts
|
||
# import ...`) — тесты подменяют sys.modules['weasyprint'] плоским MagicMock без
|
||
# реальных субмодулей, а точечный submodule-импорт на моке падает с
|
||
# "'weasyprint' is not a package"; атрибутный доступ работает и на Mock, и на
|
||
# реальном пакете (weasyprint/__init__.py уже импортирует .text.fonts внутри себя).
|
||
import weasyprint
|
||
from weasyprint import CSS, HTML
|
||
|
||
from app.services.brand import Brand
|
||
|
||
if brand is None:
|
||
brand = Brand(
|
||
slug="generic",
|
||
name="Trade-In",
|
||
logo_url=None,
|
||
primary_color="#1d4ed8",
|
||
accent_color="#f59e0b",
|
||
footer_text=None,
|
||
pdf_disclaimer=None,
|
||
)
|
||
|
||
# Sanitize brand colors before inserting into CSS/style attributes (#13).
|
||
brand = Brand(
|
||
slug=brand.slug,
|
||
name=brand.name,
|
||
logo_url=brand.logo_url,
|
||
primary_color=_safe_color(brand.primary_color, "#1d4ed8"),
|
||
accent_color=_safe_color(brand.accent_color, "#f59e0b"),
|
||
footer_text=brand.footer_text,
|
||
pdf_disclaimer=brand.pdf_disclaimer,
|
||
)
|
||
|
||
# #697/#9: insufficient data → empty-state page, NOT fabricated 0-median report.
|
||
if estimate.insufficient_data:
|
||
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
||
else:
|
||
body_html = (
|
||
_build_cover(estimate, input_snapshot, brand)
|
||
+ _build_listings_page(estimate, input_snapshot, brand)
|
||
+ _build_deals_page(estimate, input_snapshot, brand)
|
||
+ _build_offer_page(estimate, brand)
|
||
)
|
||
|
||
html_str = (
|
||
'<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">'
|
||
f"<title>Trade-In — {_html.escape(input_snapshot.get('address', ''))}</title>"
|
||
f"</head><body>{body_html}</body></html>"
|
||
)
|
||
css_str = _build_css(brand=brand)
|
||
# FontConfiguration обязателен для того, чтобы @font-face (PT Sans/PT Serif, см.
|
||
# _build_css) реально зарегистрировались и использовались при layout — без него
|
||
# WeasyPrint молча игнорирует @font-face и подставляет fallback из font-stack
|
||
# (DejaVu). Один и тот же объект передаётся в CSS(...) и write_pdf(...).
|
||
font_config = weasyprint.text.fonts.FontConfiguration()
|
||
# SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы.
|
||
# base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:).
|
||
# url_fetcher передаётся в HTML(...) и CSS(...) конструкторы, а НЕ в write_pdf() —
|
||
# в WeasyPrint 69 write_pdf(url_fetcher=...) молча игнорируется
|
||
# ("Unknown rendering option"), а @font-face-ресолвинг внутри CSS(...) происходит
|
||
# синхронно при конструировании, до write_pdf(). Один и тот же fetcher — на оба
|
||
# объекта, иначе guard не покрывает CSS-ресурсы (сейчас только data:-шрифты, но
|
||
# это тот путь, по которому в будущем может прилететь brand-specific URL).
|
||
url_fetcher = _make_safe_url_fetcher()
|
||
pdf_bytes = HTML(string=html_str, base_url=None, url_fetcher=url_fetcher).write_pdf(
|
||
stylesheets=[CSS(string=css_str, font_config=font_config, url_fetcher=url_fetcher)],
|
||
font_config=font_config,
|
||
)
|
||
logger.info(
|
||
"PDF generated estimate_id=%s brand=%s size=%d insufficient_data=%s",
|
||
estimate.estimate_id,
|
||
brand.slug,
|
||
len(pdf_bytes),
|
||
estimate.insufficient_data,
|
||
)
|
||
return pdf_bytes
|