feat(trade-in): TI-2 PDF export + frontend enable button
- Add backend/app/services/exporters/trade_in_pdf.py: 4-page WeasyPrint PDF
(Cover / Listings / Deals / Trade-in cost stub) following layout_tz_pdf.py pattern
- Extend trade_in.py: GET /api/v1/trade-in/estimate/{id}/pdf endpoint
(404 not found, 410 expired TTL, application/pdf attachment)
- Enable EstimateResult.tsx PDF button: replace disabled button with active
anchor tag using NEXT_PUBLIC_API_URL + estimate_id
Closes #314 (TI-2 + TI-4 sub-tasks)
This commit is contained in:
parent
90eb5af883
commit
7ea4f262cd
3 changed files with 529 additions and 42 deletions
|
|
@ -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}"'},
|
||||
)
|
||||
|
|
|
|||
424
backend/app/services/exporters/trade_in_pdf.py
Normal file
424
backend/app/services/exporters/trade_in_pdf.py
Normal 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">Сделка за 2–4 недели вместо 3–6 месяцев самостоятельной продажи</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
|
||||
|
|
@ -416,27 +416,23 @@ export function EstimateResult({ estimate, input }: Props) {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* PDF button (disabled) */}
|
||||
{/* PDF download button */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div
|
||||
title="Генерация PDF появится в TI-2"
|
||||
style={{ display: "inline-block", cursor: "not-allowed" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL ?? ""}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf`}
|
||||
download={`trade-in-${estimate.estimate_id}.pdf`}
|
||||
style={{
|
||||
display: "flex",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "10px 18px",
|
||||
background: "#e5e7eb",
|
||||
color: "#9ca3af",
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#1d4ed8",
|
||||
color: "#fff",
|
||||
border: "1px solid #1d4ed8",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "not-allowed",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -455,8 +451,7 @@ export function EstimateResult({ estimate, input }: Props) {
|
|||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Скачать PDF
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue