"""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 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__) # ── 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", 0) total_floors = input_snapshot.get("total_floors", 0) 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 = "1" if has_balcony else "0" 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}/trade-in?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}/trade-in?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) if lot.source_url: 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