"""PDF генератор для Trade-In Estimator — Брусника-style. Точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf. Структура отчёта — 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 + дистанция + кликабельные ссылки + QR-код. """ from __future__ import annotations import base64 import datetime as dt import html as _html import io import logging import re from urllib.parse import urlparse from uuid import UUID import segno from app.core.config import settings from app.schemas.trade_in import AggregatedEstimate, AnalogLot def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str: """SVG диапазон-бара как data URI. WeasyPrint надёжно рендерит SVG (как QR-код).""" width, height = 600, 32 x1 = width * q_low x2 = width * q_high svg = ( f'' f'' f'' f"" ) return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}" 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:). " f"URL: {url!r}" ) return fetcher # ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ── # Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится # через безопасный fallback в _source_pill: серый фон + source.title() — код на # исторических строках НЕ падает. _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, ("#6b7280", "#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 "" bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff")) name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) return ( f"{_html.escape(name)}" ) # ── Helpers ────────────────────────────────────────────────────────────────── def _fmt_rub(value: int) -> str: """12 500 000 ₽""" return f"{value:,}".replace(",", " ") + " ₽" 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 _report_number(estimate_id: UUID) -> str: """Брусника-style № отчёта: '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) # ── Price range SVG bar (как у Брусники) ──────────────────────────────────── _BAR_PNG_URL = _bar_svg_data_url() def _price_range_bar( 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, ) -> str: """Полоска диапазона цен в стиле Брусники — PNG через Pillow.""" 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) # Ц-таблица: 3 ячейки фиксированной ширины с background-color на td. # Высота через padding (WeasyPrint stable с padding на td). days_row = "" if show_days and days_min is not None and days_max is not None: days_row = ( f"" f'' f"{days_min} дней" f'' f"{days_max} дней" f"" ) return ( f'' f'" f'' f'' f'' f'' f'' f"" f'" f'" f"{days_row}" f"
' f"{_html.escape(sub_label)}" f"
' f"{_html.escape(label_left)}' f"{_html.escape(label_right)}
" ) # ── Dual price headline: «Ориентир запроса» + «Ожидаемая цена продажи» ─────── # Зеркалит web HeroSummary (#648 S5a): две равновесные цифры. Asking — медиана # по объявлениям; expected_sold — реалистичная цена сделки (asking × ratio из # asking_to_sold_ratios, migration 080). Graceful: при отсутствии sold (None/0 — # старые оценки / пустой бакет ratio-таблицы) рисуем ТОЛЬКО asking-колонку. def _discount_pct(estimate: AggregatedEstimate) -> int | None: """Скидка запрос→продажа в % (как web): из asking_to_sold_ratio при наличии, иначе из самих чисел sold/median. None если sold нет или скидка < 1%.""" sold = estimate.expected_sold_price_rub if not sold or sold <= 0: return None ratio = estimate.asking_to_sold_ratio if ratio is not None and ratio > 0: pct = round((1 - ratio) * 100) elif estimate.median_price_rub > 0: pct = round((1 - sold / estimate.median_price_rub) * 100) else: return None return pct if pct >= 1 else None def _price_figure_cell( *, label: str, value_rub: int, range_low: int | None, range_high: int | None, per_m2: int | None, accent: str, delta_pct: int | None = None, ) -> str: """Одна ценовая колонка (label + крупное число + диапазон + ₽/м²). Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении — как web (guard на typeof number). delta_pct — опциональный chip «−N%».""" delta_chip = "" if delta_pct is not None: delta_chip = ( f"−{delta_pct}%" ) range_html = "" if range_low is not None and range_high is not None and range_high > range_low: range_html = ( f"
" f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}" f"
" ) perm2_html = "" if per_m2 is not None and per_m2 > 0: perm2_html = ( f"
{_fmt_ppm2(per_m2)} ₽/м²
" ) return ( f"" f"
{_html.escape(label)}{delta_chip}
" f"
{_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="#dc2626", delta_pct=_discount_pct(estimate), ) explainer = ( '

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

" ) 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 (как у Брусники). # Если есть 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_bar( 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_bar( deals_low, deals_high, sub_label="Диапазон цен по фактическим сделкам", ) logo_url = _safe_logo_url(brand.logo_url) logo_html = ( f"{_html.escape(brand.name)}" if logo_url else f"" f"{_html.escape(brand.name).upper()}" ) disclaimer_html = "" if brand.pdf_disclaimer: disclaimer_html = ( f"

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

" ) return f"""
{logo_html}

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

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

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

{listings_bar}

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

{deals_bar}

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

Цены в объявлениях — ожидания собственников Фактические сделки проходят на 5–12% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости
Ремонт оценивается по состоянию, а не по вложенным суммам Инвестиции в 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_bar( 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"""
{_html.escape(brand.name).upper()}

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

Количество объявлений по аналогичным объектам {n_total} шт.
Источники данных
{sources_html}
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{year_range}
Диапазон площади{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"{ppm2_cell}" f"{price_cell}" f"{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_bar( deals_low, deals_high, sub_label="Рынок", ) top5 = estimate.actual_deals[:5] examples_rows = _examples_rows(top5) return f"""
{_html.escape(brand.name).upper()}

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

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

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

{range_bar}
По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле, чем заявлено в объявлениях

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

{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) # После торга — средний по диапазону для остальных % sold_price = listing_price - int(listing_price * 0.10) # ~10% торг rieltor_pct_low, rieltor_pct_high = 2, 3 rieltor_low = int(sold_price * rieltor_pct_low / 100) rieltor_high = int(sold_price * rieltor_pct_high / 100) rent_low, rent_high = 90_000, 150_000 # 3 месяца аренды (фикс) juridical_low = 19_250 # фикс ads_low, ads_high = 4_000, 36_000 # за 3 месяца total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low total_high = torg_high + rieltor_high + rent_high + juridical_low + ads_high brand_short = _html.escape(brand.name) trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In" return f"""
{brand_short.upper()}

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

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

Продажа через
{trade_in_label}
Самостоятельная продажа, руб.
Торг потенциальных покупателей
при стоимости квартиры в объявлении {_fmt_rub(listing_price)}
Не применимо от {_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}
от {torg_pct_low}–{torg_pct_high}%
Услуги риэлтора при продаже квартиры
с минимальным торгом за {_fmt_rub(sold_price)}
бесплатно {_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}
от {rieltor_pct_low}–{rieltor_pct_high}%
Аренда после сделки
однокомнатной квартиры на 3 месяца
бесплатно {_fmt_rub(rent_low)} - {_fmt_rub(rent_high)}
Юридическое сопровождение
Проверка документов и подготовка договоров
бесплатно от {_fmt_rub(juridical_low)}
Расходы на рекламу
Ежемесячное базовой продвижение объекта на Циан, Авито, Я.Недвижимости
бесплатно от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}
за 3 месяца
Общие финансовые потери от {_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""" @page {{ size: A4; margin: 20mm 18mm 20mm 18mm; }} * {{ box-sizing: border-box; }} body {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 10pt; color: #1a1d23; margin: 0; padding: 0; line-height: 1.4; }} h1, h2, h3 {{ margin: 0; }} .page {{ page-break-after: always; position: relative; }} .page:last-child {{ page-break-after: avoid; }} .bold {{ font-weight: 700; }} /* Params table (Cover) */ .params-table td {{ padding: 2.5pt 4pt; border-bottom: 1px solid #f3f4f6; font-size: 9pt; }} .params-table td:first-child {{ color: #6b7280; width: 40%; }} .params-table td:last-child {{ text-align: right; }} /* Search params (Listings/Deals) */ .search-params td {{ padding: 4pt 0; font-size: 9pt; }} .search-params td:first-child {{ color: #6b7280; }} .search-params td:last-child {{ text-align: right; }} /* Range block */ .range-block {{ margin-top: 6pt; }} /* Range bar — linear-gradient на одном div'е. WeasyPrint reliable. */ .range-bar-strip {{ width: 100%; height: 16pt; line-height: 14pt; border: 1pt solid #9ca3af; background: linear-gradient( to right, #ffffff 0%, #ffffff 20%, #dc2626 20%, #dc2626 80%, #ffffff 80%, #ffffff 100% ); margin: 4pt 0; overflow: hidden; color: transparent; font-size: 1pt; }} .range-title {{ font-size: 10pt; font-weight: 700; color: #1a1d23; margin-bottom: 4pt; }} /* Что важно — advice table */ .advice-table td {{ padding: 5pt 6pt; border-bottom: 1px solid #f3f4f6; vertical-align: top; font-size: 8.5pt; line-height: 1.3; }} .advice-title {{ font-weight: 700; width: 30%; color: #1a1d23; }} .advice-text {{ color: #4b5563; }} /* Advantages (Offer) */ .advantage-item {{ border: 1px solid #e6e8ec; border-radius: 4pt; padding: 8pt; text-align: left; }} .advantage-item .adv-ic {{ display: block; width: 28pt; height: 28pt; border-radius: 50%; background: #fef2f2; 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: 8.5pt; margin-bottom: 3pt; color: {primary}; letter-spacing: -0.01em; }} .advantage-item .adv-desc {{ font-size: 7pt; color: #5b6066; 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"""
{_html.escape(brand.name).upper()}

№ {report_num} · {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 в стиле Брусника.Обмен. Если 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. 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) # SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы. # base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:). pdf_bytes = HTML(string=html_str, base_url=None).write_pdf( stylesheets=[CSS(string=css_str)], url_fetcher=_make_safe_url_fetcher(), ) 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