gendesign/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
lekss361 5cbf3485a0
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
fix(tradein/pdf): честный embedded-шрифт (PT Sans/PT Serif) + matplotlib-график вместо div (#2427)
2026-07-04 14:39:37 +00:00

1577 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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'&#8722;{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))} &#8211; {_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)} &#8381;/м²')}</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">Фактические сделки проходят на 1018% ниже, что подтверждают
Росреестр, ДомКлик и продажи агентств недвижимости</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;">
По данным реальных сделок, квартиры продаются в среднем на 1018% дешевле,
чем заявлено в объявлениях (Екатеринбург, 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