From 3978aa59050612ed8f9eaa7a66ec03cd079e82e1 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 17:16:48 +0000 Subject: [PATCH] feat(trade-in): TI-2 PDF export 4-page + frontend enable button (#319) --- backend/app/api/v1/trade_in.py | 76 +++- .../app/services/exporters/trade_in_pdf.py | 424 ++++++++++++++++++ .../components/trade-in/EstimateResult.tsx | 71 ++- 3 files changed, 529 insertions(+), 42 deletions(-) create mode 100644 backend/app/services/exporters/trade_in_pdf.py 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} + + + + +
+
+

Оценка квартиры — 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 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 +
);