"""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) ───────────────────────────────────── # Только доверенные домены-источники объявлений попадают в в 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"{_html.escape(name)}" ) def _source_badge_inline(source: str | None) -> str: """Маленький source badge для table cells (без фона).""" if not source: return f"" bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff")) name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) return ( f"{_html.escape(name)}" ) # ── 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"{text}" 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"{_html.escape(brand.name)}" if logo_url else f"{_html.escape(brand.name).upper()}" ) return ( f'
' f"{mark_html}" f'Trade-In отчёт' f"
" ) # ── 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 — 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 ). Подложка-трек (светло-серый) + закрашенная акцентная зона на реальном диапазоне [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'
' f'
{_html.escape(sub_label)}
' f'{_html.escape(sub_label)}' f"
" ) # ── 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"{_mono(f'−{delta_pct}%')}" ) 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"
" f"{_mono(range_text)}
" ) perm2_html = "" if per_m2 is not None and per_m2 > 0: perm2_html = ( f"
" f"{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}
" ) return ( f"" f"
{_html.escape(label)}{delta_chip}
" f"
" f"{_mono(_html.escape(_fmt_rub_m(value_rub)))}
" f"{range_html}{perm2_html}" f"" ) 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'{single}
' ) 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'

' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." "

" ) return ( f'{asking_cell}{sold_cell}
' 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"

" f"{_html.escape(brand.pdf_disclaimer)}

" ) return f"""
{_page_header(brand)}

АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ

№ отчета{_mono(report_num)}
Дата отчета{_mono(today.strftime("%d.%m.%Y"))}
Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))}
Адрес{address}
Год постройки{_mono(str(year_built))}
Тип дома{house_label}
Этаж / этажность{_mono(f"{floor} / {total_floors}")}
Площадь{_mono(f"{area} м²")}
Планировка{rooms_label}
Состояние ремонта{repair_label}
Балкон / лоджия{balcony_label}
{_dual_price_block(estimate, brand)}

Диапазон цен в объявлениях (без учета ремонта)

{listings_bar}

Диапазон цен по фактическим сделкам

{deals_bar}

Что важно при оценке стоимости квартиры:

Цены в объявлениях — ожидания собственников Фактические сделки проходят на 10–18% ниже, что подтверждают Росреестр, ДомКлик и продажи агентств недвижимости
Ремонт оценивается по состоянию, а не по вложенным суммам Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры
Неочевидные расходы при самостоятельной продаже При самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки)

Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}

{disclaimer_html}
""" 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"""
{_page_header(brand)}

РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ

Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")}
Источники данных
{sources_html}
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{_mono(year_range)}
Диапазон площади {_mono(f"{area_min} - {area_max} м²")}
Планировка{rooms_label}
Состояние ремонта{repair_label}

Диапазон цен в объявлениях

{range_bar}

Примеры аналогичных объектов с учётом ремонта

{examples_rows}
Адрес квартиры Источник Стоимость 1 м², руб. Стоимость объекта, руб. Срок экспозиции, дней
""" def _examples_rows(lots: list[AnalogLot]) -> str: if not lots: return ( "Нет данных" ) rows = [] for lot in lots: addr = _html.escape(lot.address) safe = _safe_url(lot.source_url) if safe: addr_cell = ( f"
{addr} ↗" ) 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( "" f"{addr_cell}" f"" f"{_source_badge_inline(lot.source)}" f"" f"{_mono(str(ppm2_cell))}" f"" f"{_mono(str(price_cell))}" f"" f"{_mono(str(days_cell))}" "" ) 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"""
{_page_header(brand)}

ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ

Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Источники данных
{sources_html}
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{_mono(year_range)}
Диапазон площади {_mono(f"{area_min} - {area_max} м²")}
Планировка{rooms_label}

Диапазон цен по фактическим сделкам

{range_bar}
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле, чем заявлено в объявлениях (Екатеринбург, 2026)

Примеры аналогичных объектов

{examples_rows}
Адрес квартиры Источник Стоимость 1 м², руб. Стоимость объекта, руб. Срок экспозиции, дней
""" # ── 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"""
{_page_header(brand)}

ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ

Структура стоимости сделки

{trade_in_label} Самостоятельная продажа, руб.
Торг потенциальных покупателей
при стоимости квартиры в объявлении {_mono(_fmt_rub(listing_price))}
Не применимо от {_mono(f"{_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}")}
{_mono(f"от {torg_pct_low}–{torg_pct_high}%")}
Услуги риэлтора при продаже квартиры
с минимальным торгом за {_mono(_fmt_rub(sold_price))}
бесплатно {_mono(f"{_fmt_rub(rieltor_low)} – {_fmt_rub(rieltor_high)}")}
{_mono(f"от {rieltor_pct_low}–{rieltor_pct_high}%")}
Аренда после сделки
однокомнатной квартиры на 3 месяца
бесплатно {_mono(f"{_fmt_rub(rent_low)} – {_fmt_rub(rent_high)}")}
Юридическое сопровождение
Проверка документов и подготовка договоров
бесплатно от {_mono(f"{_fmt_rub(juridical_low)} – {_fmt_rub(juridical_high)}")}
Расходы на рекламу
Ежемесячное базовое продвижение объекта на Циан, Авито, Я.Недвижимости
бесплатно от {_mono(f"{_fmt_rub(ads_low)} – {_fmt_rub(ads_high)}")}
за 3 месяца, ориентировочно
Общие финансовые потери от {_mono(f"{_fmt_rub(total_low)} – {_fmt_rub(total_high)}")}
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при самостоятельной продаже квартиры

Преимущества

Экономия времени
Берём на себя показы, переговоры и поиск покупателей
Юридическая безопасность
Проверяем документы, исключаем риски, сопровождаем на всех этапах
Фиксированная стоимость новостройки
Сохраняем цену выбранной планировки в {brand_short}
Гарантия цены
Снимаем угрозу колебаний рынка и удерживаем договорную стоимость
""" # ── 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"""
{_page_header(brand)}

№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}

Недостаточно данных для оценки

По данному объекту не найдено достаточного количества аналогов для формирования надёжной рыночной оценки. Пожалуйста, уточните параметры или обратитесь к специалисту.

""" 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 = ( '' f"Trade-In — {_html.escape(input_snapshot.get('address', ''))}" f"{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