"""PDF генератор для отчёта Trade-In Estimator (TI-2). Структура отчёта — 4 страницы (как у Брусника.Обмен): 1. Cover — шапка + адрес + параметры квартиры + hero-цена 2. Listings — таблица объявлений-аналогов (top 10) 3. Deals — таблица фактических сделок (last 12 мес.) 4. Offer — выкупная стоимость trade-in (placeholder Phase 2) Pattern: backend/app/services/exporters/layout_tz_pdf.py (WeasyPrint, уже работает). """ from __future__ import annotations import datetime as dt import html as _html import logging from weasyprint import CSS, HTML from app.schemas.trade_in import AggregatedEstimate, AnalogLot logger = logging.getLogger(__name__) # ── helpers ────────────────────────────────────────────────────────────────── def _fmt_rub(value: int) -> str: """Форматирование рублей с пробелами-разделителями: 12 500 000 ₽""" return f"{value:,}".replace(",", " ") + " ₽" def _fmt_rub_m(value: int) -> str: """Форматирование в миллионах: 12,50 млн ₽""" m = value / 1_000_000 return f"{m:.2f}".replace(".", ",") + " млн ₽" def _fmt_ppm2(value: int) -> str: return f"{value:,}".replace(",", " ") + " ₽/м²" def _conf_color(confidence: str) -> tuple[str, str, str]: """(bg, fg, border) для badge уверенности.""" mapping = { "high": ("#dcfce7", "#15803d", "#86efac"), "medium": ("#fef9c3", "#a16207", "#fde68a"), "low": ("#fee2e2", "#b91c1c", "#fca5a5"), } return mapping.get(confidence, ("#f3f4f6", "#374151", "#d1d5db")) def _conf_label(confidence: str) -> str: return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence) def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str: if not lots: return "Нет данных" rows = [] for lot in lots: date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—" dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "—" floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "—" label = "Дата сделки" if is_deal else "В продаже" _ = label # used for header only rows.append( "" f"{_html.escape(lot.address)}" f"{lot.area_m2:.1f}" f"{lot.rooms if lot.rooms else 'С'}" f"{floor_val}" f"{_fmt_rub(lot.price_rub)}" f"{date_val} ({dom_val}д.)" "" ) return "".join(rows) # ── CSS ────────────────────────────────────────────────────────────────────── def _build_css() -> str: return """ @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; } h1 { font-size: 18pt; margin: 0 0 4pt 0; line-height: 1.2; } h2 { font-size: 13pt; margin: 0 0 8pt 0; border-bottom: 1.5px solid #e6e8ec; padding-bottom: 4pt; } h3 { font-size: 11pt; margin: 0 0 6pt 0; color: #374151; } .page { page-break-after: always; } .page:last-child { page-break-after: avoid; } /* Cover */ .cover-header { background: #1d4ed8; color: #fff; padding: 20pt; border-radius: 6pt; margin-bottom: 16pt; } .cover-header h1 { color: #fff; } .cover-meta { font-size: 9pt; color: rgba(255,255,255,0.8); margin-top: 4pt; } .hero-price { font-size: 28pt; font-weight: 800; font-variant-numeric: tabular-nums; margin: 12pt 0 4pt; } .hero-ppm2 { font-size: 12pt; color: #5b6066; font-variant-numeric: tabular-nums; } .range-bar-wrap { margin: 10pt 0; } .range-label { font-size: 9pt; color: #9ca3af; } .badge { display: inline-block; padding: 4pt 10pt; border-radius: 6pt; font-size: 9pt; font-weight: 700; margin-top: 8pt; } .params-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8pt; margin-top: 10pt; } .param-item .param-label { font-size: 8pt; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.03em; } .param-item .param-value { font-size: 10pt; font-weight: 600; } /* Tables */ table { width: 100%; border-collapse: collapse; margin-top: 8pt; font-size: 9pt; } thead tr { background: #f3f4f6; } th { padding: 5pt 7pt; text-align: left; font-weight: 700; color: #374151; border-bottom: 1.5px solid #d1d5db; } td { padding: 5pt 7pt; border-bottom: 1px solid #e6e8ec; vertical-align: top; } td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } tr:nth-child(even) td { background: #f9fafb; } td.empty { text-align: center; color: #9ca3af; padding: 14pt; } /* Offer page */ .offer-table { margin-top: 14pt; } .offer-table td { font-size: 10pt; } .offer-total td { font-weight: 700; background: #eff6ff !important; } .advantages { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-top: 16pt; } .advantage-item { border: 1px solid #e6e8ec; border-radius: 6pt; padding: 10pt; } .advantage-item .adv-title { font-weight: 700; font-size: 10pt; margin-bottom: 4pt; } .advantage-item .adv-desc { font-size: 9pt; color: #5b6066; } /* Footer */ .footer { margin-top: 20pt; padding-top: 8pt; border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af; } .source-logos { color: #6b7280; font-size: 8pt; margin-top: 4pt; } """ # ── HTML builder ───────────────────────────────────────────────────────────── def _build_html(estimate: AggregatedEstimate, input_snapshot: dict) -> str: # type: ignore[type-arg] today = dt.date.today().strftime("%d.%m.%Y") address = _html.escape(input_snapshot.get("address", "—")) area_m2: float = input_snapshot.get("area_m2", 0) rooms: int = input_snapshot.get("rooms", 0) floor: int = input_snapshot.get("floor", 0) total_floors: int = 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") conf_bg, conf_fg, conf_border = _conf_color(estimate.confidence) conf_label = _conf_label(estimate.confidence) rooms_label = "Студия" if rooms == 0 else f"{rooms}-комн." house_labels = { "panel": "Панель", "brick": "Кирпич", "monolith": "Монолит", "monolith_brick": "Монолит-кирпич", "other": "Другое", } repair_labels = { "needs_repair": "Требует ремонта", "standard": "Стандартный", "good": "Хороший", "excellent": "Евроремонт", } # Cover params grid items params = [ ("Площадь", f"{area_m2} м²"), ("Комнат", rooms_label), ("Этаж", f"{floor} из {total_floors}"), ] if year_built: params.append(("Год постройки", str(year_built))) if house_type: params.append(("Тип дома", house_labels.get(house_type, house_type))) if repair_state: params.append(("Ремонт", repair_labels.get(repair_state, repair_state))) if has_balcony is not None: params.append(("Балкон", "Есть" if has_balcony else "Нет")) params_html = "".join( f"""
{k}
{_html.escape(str(v))}
""" for k, v in params ) listing_rows = _analog_rows(estimate.analogs, is_deal=False) deal_rows = _analog_rows(estimate.actual_deals, is_deal=True) # Trade-in cost table (stub: -7% buyout) median = estimate.median_price_rub torg_pct = 7 buyout_pct = 5 # trade-in discount on top torg = int(median * torg_pct / 100) buyout_discount = int(median * buyout_pct / 100) tradein_price = median - torg - buyout_discount return f""" Trade-In Оценка — {address}

Оценка квартиры — Trade-In

{address}
Сформировано: {today} · Действительно 24 часа
Оценочная стоимость
{_fmt_rub_m(estimate.median_price_rub)}
{_fmt_ppm2(estimate.median_price_per_m2)}
Диапазон: {_fmt_rub_m(estimate.range_low_rub)} — {_fmt_rub_m(estimate.range_high_rub)} · данные за {estimate.period_months} мес.
Достоверность: {conf_label}
{estimate.n_analogs} аналогов

Параметры объекта

{params_html}

Объявления-аналоги ({len(estimate.analogs)})

Аналоги из открытых источников (Циан, Авито, ДомКлик) — схожая комнатность, площадь ±15%, тот же район. Используются как ориентир рыночной цены.

{listing_rows}
Адрес Пл., м² Комн. Этаж Цена Дата / Экспозиция

Фактические сделки ({len(estimate.actual_deals)})

Сделки из Росреестра (ДДУ + переуступка) за последние 12 месяцев — отражают реальные цены покупки, в отличие от цен предложения.

{deal_rows}
Адрес Пл., м² Комн. Этаж Цена сделки Дата / Срок

Выкупная стоимость (trade-in)

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

Рыночная стоимость (медиана) {_fmt_rub(median)}
— Торговый дисконт ({torg_pct}%) −{_fmt_rub(torg)}
— Дисконт за срочность выкупа ({buyout_pct}%) −{_fmt_rub(buyout_discount)}
Выкупная цена (ориентир) {_fmt_rub(tradein_price)}
Важно: данные получены из mock-источников (MVP Phase 1). В Phase 2 расчёт будет основан на реальных данных Циан/Авито/Росреестр для конкретного адреса и подтверждён менеджером.

4 преимущества trade-in

Скорость
Сделка за 2–4 недели вместо 3–6 месяцев самостоятельной продажи
Без хлопот
Показы, торг, документы — всё берёт на себя девелопер
Зачёт в счёт новостройки
Стоимость квартиры напрямую идёт в оплату нового жилья
Фиксация цены
Цена новостройки фиксируется на момент подачи заявки
""" # ── public API ──────────────────────────────────────────────────────────────── def generate_trade_in_pdf(estimate: AggregatedEstimate, input_snapshot: dict) -> bytes: # type: ignore[type-arg] """Генерирует 4-страничный WeasyPrint PDF для Trade-In оценки. Pages: 1. Cover — шапка + адрес + параметры + hero-цена 2. Listings — таблица объявлений-аналогов (top 10) 3. Deals — таблица фактических сделок (last 12 мес.) 4. Offer — выкупная стоимость trade-in + 4 преимущества Args: estimate: AggregatedEstimate из БД input_snapshot: словарь с полями ввода пользователя (address, area_m2, ...) Returns: PDF bytes готовые для Response(media_type="application/pdf") """ html_str = _build_html(estimate, input_snapshot) css_str = _build_css() pdf_bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=css_str)]) logger.info( "Generated trade-in PDF estimate_id=%s pages=4 size=%d bytes", estimate.estimate_id, len(pdf_bytes), ) return pdf_bytes