"""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 from urllib.parse import urlparse from uuid import UUID import segno from PIL import Image, ImageDraw from weasyprint import CSS, HTML 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", "n1.ru", "ekaterinburg.n1.ru", }) # CDN-домены для изображений аналогов (photo_url). _ALLOWED_PHOTO_CDN: frozenset[str] = frozenset({ "images.avito.st", "cdn-p.cian.site", "avatars.mds.yandex.net", "n1ru.cdn-cw.com", }) 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 # ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ── _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { "avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо) "cian": ("#0468ff", "#fff"), # Циан фирменный синий "domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер "yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный "n1": ("#ff6b00", "#fff"), # N1 оранжевый "rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий "etazhi": ("#e30613", "#fff"), # Этажи красный } _SOURCE_DISPLAY_NAMES: dict[str, str] = { "avito": "Avito", "cian": "Циан", "domklik": "Домклик · Сбер", "yandex": "Я.Недвижимость", "n1": "N1", "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) days_overlay = "" if show_days and days_min is not None and days_max is not None: days_overlay = ( f'' f'' f'' f'' f'' f'
' f'{days_min} дней' f'' f'{days_max} дней' f'
' ) # Ц-таблица: 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)}
' ) # ── 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 = 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="Диапазон цен по фактическим сделкам", ) qr_url = _qr_code_data_url( f"{settings.public_url}?id={estimate.estimate_id}", size=3 ) return f"""
{_html.escape(brand.name).upper()}

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

№ отчета{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}

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

{listings_bar}

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

{deals_bar}

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

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

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

""" 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 n_with_repair = max(3, int(n_total * 0.4)) # упрощение: ~40% с указанным ремонтом # Source logos (pseudo) — 5 максимальных sources_to_show = estimate.sources_used or ["avito", "cian", "domklik", "yandex", "rosreestr"] 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} шт.
Количество объявлений по аналогичным объектам
(с учётом ремонта)
{n_with_repair} шт.
Источники данных
{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 rows.append( "" f"{addr_cell}" f"{_source_badge_inline(lot.source)}" f"{_fmt_ppm2(lot.price_per_m2)}" f"{_fmt_ppm2(lot.price_rub)}" f"{lot.days_on_market or '—'}" "" ) 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) # Источники для сделок — Этажи / Домклик / Росреестр deal_sources = ["etazhi", "domklik", "rosreestr"] sources_html = "".join(_source_logo_pill(s) for s in deal_sources) 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": "Современный (монолит)", }.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)) days_min, days_max = _days_on_market_range(estimate.actual_deals) range_bar = _price_range_bar( deals_low, deals_high, sub_label="Рынок", days_min=days_min, days_max=days_max, show_days=True, ) 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 generate_trade_in_pdf( estimate: AggregatedEstimate, input_snapshot: dict, # type: ignore[type-arg] *, brand=None, # type: ignore[no-untyped-def] ) -> bytes: """Генерирует 4-страничный WeasyPrint PDF в стиле Брусника.Обмен. Pages: 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") """ 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, ) html_str = ( '' f'Trade-In — {_html.escape(input_snapshot.get("address", ""))}' "" + _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) + "" ) css_str = _build_css(brand=brand) # base_url=file:/// чтобы WeasyPrint мог resolve file:// URLs для PNG pdf_bytes = HTML(string=html_str, base_url="file:///").write_pdf(stylesheets=[CSS(string=css_str)]) logger.info( "PDF generated estimate_id=%s brand=%s size=%d (Brusnika-style)", estimate.estimate_id, brand.slug, len(pdf_bytes), ) return pdf_bytes