feat(trade-in): TI-2 PDF export 4-page + frontend enable button #319

Merged
lekss361 merged 1 commit from feat/ti-2-pdf into main 2026-05-17 17:16:49 +00:00
3 changed files with 529 additions and 42 deletions

View file

@ -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. MOCK implementation: returns realistic ЕКБ price bands by rooms/floor/repair.
TODO TI-1b: заменить _mock_estimate() на реальный SQL aggregation из TODO TI-1b: заменить _mock_estimate() на реальный SQL aggregation из
@ -14,12 +14,13 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated from typing import Annotated
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import get_db from app.core.db import get_db
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput 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__) logger = logging.getLogger(__name__)
@ -280,8 +281,6 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк. Возвращает 404 если оценка не найдена или TTL истёк.
""" """
from fastapi import HTTPException
row = db.execute( row = db.execute(
text( text(
""" """
@ -315,3 +314,72 @@ def get_estimate(
actual_deals=actual_deals, actual_deals=actual_deals,
expires_at=row.expires_at, 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}"'},
)

View file

@ -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 "<tr><td colspan='6' class='empty'>Нет данных</td></tr>"
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(
"<tr>"
f"<td>{_html.escape(lot.address)}</td>"
f"<td class='num'>{lot.area_m2:.1f}</td>"
f"<td class='num'>{lot.rooms if lot.rooms else 'С'}</td>"
f"<td class='num'>{floor_val}</td>"
f"<td class='num'>{_fmt_rub(lot.price_rub)}</td>"
f"<td class='num'>{date_val} ({dom_val}д.)</td>"
"</tr>"
)
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"""<div class="param-item">
<div class="param-label">{k}</div>
<div class="param-value">{_html.escape(str(v))}</div>
</div>"""
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"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Trade-In Оценка {address}</title>
</head>
<body>
<!-- PAGE 1 COVER -->
<div class="page">
<div class="cover-header">
<h1>Оценка квартиры Trade-In</h1>
<div class="cover-meta">{address}</div>
<div class="cover-meta">Сформировано: {today} · Действительно 24 часа</div>
</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16pt;">
<div>
<div style="font-size:9pt; color:#5b6066; text-transform:uppercase; letter-spacing:0.05em;">
Оценочная стоимость
</div>
<div class="hero-price">{_fmt_rub_m(estimate.median_price_rub)}</div>
<div class="hero-ppm2">{_fmt_ppm2(estimate.median_price_per_m2)}</div>
<div class="range-label" style="margin-top:8pt;">
Диапазон: {_fmt_rub_m(estimate.range_low_rub)} {_fmt_rub_m(estimate.range_high_rub)}
· данные за {estimate.period_months} мес.
</div>
</div>
<div class="badge"
style="background:{conf_bg}; color:{conf_fg}; border:1px solid {conf_border};">
Достоверность: {conf_label}<br>
<span style="font-size:8pt; font-weight:400;">{estimate.n_analogs} аналогов</span>
</div>
</div>
<h3 style="margin-top:16pt;">Параметры объекта</h3>
<div class="params-grid">
{params_html}
</div>
<div class="footer">
<div>GenDesign · Trade-In Estimator · mock-данные (Phase 1 MVP)</div>
<div class="source-logos">Источники данных: Циан · Авито · ДомКлик · Росреестр</div>
</div>
</div>
<!-- PAGE 2 LISTINGS -->
<div class="page">
<h2>Объявления-аналоги ({len(estimate.analogs)})</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Аналоги из открытых источников (Циан, Авито, ДомКлик) схожая комнатность,
площадь ±15%, тот же район. Используются как ориентир рыночной цены.
</p>
<table>
<thead>
<tr>
<th>Адрес</th>
<th>Пл., м²</th>
<th>Комн.</th>
<th>Этаж</th>
<th>Цена</th>
<th>Дата / Экспозиция</th>
</tr>
</thead>
<tbody>
{listing_rows}
</tbody>
</table>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 2</div>
</div>
</div>
<!-- PAGE 3 DEALS -->
<div class="page">
<h2>Фактические сделки ({len(estimate.actual_deals)})</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Сделки из Росреестра (ДДУ + переуступка) за последние 12 месяцев отражают
реальные цены покупки, в отличие от цен предложения.
</p>
<table>
<thead>
<tr>
<th>Адрес</th>
<th>Пл., м²</th>
<th>Комн.</th>
<th>Этаж</th>
<th>Цена сделки</th>
<th>Дата / Срок</th>
</tr>
</thead>
<tbody>
{deal_rows}
</tbody>
</table>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 3</div>
</div>
</div>
<!-- PAGE 4 TRADE-IN COST -->
<div class="page">
<h2>Выкупная стоимость (trade-in)</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Ориентировочный расчёт. Финальная цена выкупа согласовывается с менеджером
и зависит от конкретного объекта и условий сделки.
</p>
<table class="offer-table">
<tbody>
<tr>
<td>Рыночная стоимость (медиана)</td>
<td class="num">{_fmt_rub(median)}</td>
</tr>
<tr>
<td> Торговый дисконт ({torg_pct}%)</td>
<td class="num">{_fmt_rub(torg)}</td>
</tr>
<tr>
<td> Дисконт за срочность выкупа ({buyout_pct}%)</td>
<td class="num">{_fmt_rub(buyout_discount)}</td>
</tr>
<tr class="offer-total">
<td>Выкупная цена (ориентир)</td>
<td class="num">{_fmt_rub(tradein_price)}</td>
</tr>
</tbody>
</table>
<div style="margin-top:14pt; padding:10pt; background:#eff6ff; border-left:3px solid #1d4ed8;
border-radius:0 4pt 4pt 0; font-size:9pt; color:#1e40af;">
<strong>Важно:</strong> данные получены из mock-источников (MVP Phase 1).
В Phase 2 расчёт будет основан на реальных данных Циан/Авито/Росреестр
для конкретного адреса и подтверждён менеджером.
</div>
<h3 style="margin-top:18pt;">4 преимущества trade-in</h3>
<div class="advantages">
<div class="advantage-item">
<div class="adv-title">Скорость</div>
<div class="adv-desc">Сделка за 24 недели вместо 36 месяцев самостоятельной продажи</div>
</div>
<div class="advantage-item">
<div class="adv-title">Без хлопот</div>
<div class="adv-desc">Показы, торг, документы всё берёт на себя девелопер</div>
</div>
<div class="advantage-item">
<div class="adv-title">Зачёт в счёт новостройки</div>
<div class="adv-desc">Стоимость квартиры напрямую идёт в оплату нового жилья</div>
</div>
<div class="advantage-item">
<div class="adv-title">Фиксация цены</div>
<div class="adv-desc">Цена новостройки фиксируется на момент подачи заявки</div>
</div>
</div>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 4</div>
<div class="source-logos">
Расчёт носит ориентировочный характер и не является офертой.
</div>
</div>
</div>
</body>
</html>"""
# ── 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

View file

@ -416,47 +416,42 @@ export function EstimateResult({ estimate, input }: Props) {
</div> </div>
</Card> </Card>
{/* PDF button (disabled) */} {/* PDF download button */}
<div style={{ display: "flex", justifyContent: "flex-end" }}> <div style={{ display: "flex", justifyContent: "flex-end" }}>
<div <a
title="Генерация PDF появится в TI-2" href={`${process.env.NEXT_PUBLIC_API_URL ?? ""}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf`}
style={{ display: "inline-block", cursor: "not-allowed" }} download={`trade-in-${estimate.estimate_id}.pdf`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "10px 18px",
background: "#1d4ed8",
color: "#fff",
border: "1px solid #1d4ed8",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
textDecoration: "none",
}}
> >
<button <svg
type="button" width="16"
disabled height="16"
style={{ viewBox="0 0 24 24"
display: "flex", fill="none"
alignItems: "center", stroke="currentColor"
gap: 6, strokeWidth="2"
padding: "10px 18px", strokeLinecap="round"
background: "#e5e7eb", strokeLinejoin="round"
color: "#9ca3af", aria-hidden="true"
border: "1px solid #d1d5db",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: "not-allowed",
}}
> >
<svg <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
width="16" <polyline points="7 10 12 15 17 10" />
height="16" <line x1="12" y1="15" x2="12" y2="3" />
viewBox="0 0 24 24" </svg>
fill="none" Скачать PDF
stroke="currentColor" </a>
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Скачать PDF
</button>
</div>
</div> </div>
</div> </div>
); );