diff --git a/backend/app/api/v1/trade_in.py b/backend/app/api/v1/trade_in.py
index 6296c4ef..ed771099 100644
--- a/backend/app/api/v1/trade_in.py
+++ b/backend/app/api/v1/trade_in.py
@@ -1,4 +1,4 @@
-"""Trade-In Estimator — mock endpoint (TI-1).
+"""Trade-In Estimator — endpoints (TI-1 mock + TI-2 PDF).
MOCK implementation: returns realistic ЕКБ price bands by rooms/floor/repair.
TODO TI-1b: заменить _mock_estimate() на реальный SQL aggregation из
@@ -14,12 +14,13 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated
from uuid import UUID, uuid4
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput
+from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
logger = logging.getLogger(__name__)
@@ -280,8 +281,6 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк.
"""
- from fastapi import HTTPException
-
row = db.execute(
text(
"""
@@ -315,3 +314,72 @@ def get_estimate(
actual_deals=actual_deals,
expires_at=row.expires_at,
)
+
+
+@router.get("/estimate/{estimate_id}/pdf")
+def estimate_pdf(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> Response:
+ """Скачать 4-страничный PDF-отчёт для оценки trade-in.
+
+ Возвращает application/pdf attachment.
+ 404 — оценка не найдена.
+ 410 — оценка просрочена (TTL 24ч).
+ """
+ row = db.execute(
+ text(
+ """
+ SELECT id, median_price, range_low, range_high, median_price_per_m2,
+ confidence, n_analogs, analogs, actual_deals, expires_at,
+ address, area_m2, rooms, floor, total_floors,
+ year_built, house_type, repair_state, has_balcony
+ FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+
+ if row is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
+ raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
+
+ analogs = [AnalogLot(**a) for a in (row.analogs or [])]
+ actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
+
+ estimate = AggregatedEstimate(
+ estimate_id=row.id,
+ median_price_rub=row.median_price,
+ range_low_rub=row.range_low,
+ range_high_rub=row.range_high,
+ median_price_per_m2=row.median_price_per_m2,
+ confidence=row.confidence,
+ n_analogs=row.n_analogs,
+ period_months=24,
+ analogs=analogs,
+ actual_deals=actual_deals,
+ expires_at=row.expires_at,
+ )
+ input_snapshot = {
+ "address": row.address,
+ "area_m2": row.area_m2,
+ "rooms": row.rooms,
+ "floor": row.floor,
+ "total_floors": row.total_floors,
+ "year_built": row.year_built,
+ "house_type": row.house_type,
+ "repair_state": row.repair_state,
+ "has_balcony": row.has_balcony,
+ }
+
+ pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot)
+ filename = f"trade-in-{estimate_id}.pdf"
+ logger.info("PDF generated estimate_id=%s size=%d", estimate_id, len(pdf_bytes))
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/backend/app/services/exporters/trade_in_pdf.py b/backend/app/services/exporters/trade_in_pdf.py
new file mode 100644
index 00000000..761382c3
--- /dev/null
+++ b/backend/app/services/exporters/trade_in_pdf.py
@@ -0,0 +1,424 @@
+"""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}
+
+
+
+
+
+
+
+
+
+
+ Оценочная стоимость
+
+
{_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
diff --git a/frontend/src/components/trade-in/EstimateResult.tsx b/frontend/src/components/trade-in/EstimateResult.tsx
index 1e81a27b..35072145 100644
--- a/frontend/src/components/trade-in/EstimateResult.tsx
+++ b/frontend/src/components/trade-in/EstimateResult.tsx
@@ -416,47 +416,42 @@ export function EstimateResult({ estimate, input }: Props) {
- {/* PDF button (disabled) */}
+ {/* PDF download button */}
-
-
-
-
-
-
-
- Скачать PDF
-
-
+
+
+
+
+ Скачать PDF
+
);