"""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