gendesign/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
lekss361 5e3ba351a0
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy Trade-In / deploy (push) Successful in 25s
fix(tradein): security hotfix — C-3 config default + C-4 empty estimate persist + C-6 PDF URL allowlist (#435)
2026-05-23 09:38:23 +00:00

1021 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PDF генератор для Trade-In Estimator — Брусника-style (точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf).
Структура отчёта — 4 страницы:
1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
2. Listings — рынок квартир-аналогов по объявлениям + таблица примеров
3. Deals — фактические сделки + таблица примеров
4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества
White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
Расширения над Брусникой (сохранены): source badges + дистанция + кликабельные ссылки + QR-код.
"""
from __future__ import annotations
import base64
import datetime as dt
import html as _html
import io
import logging
from urllib.parse import urlparse
from uuid import UUID
import segno
from PIL import Image, ImageDraw
from weasyprint import CSS, HTML
from app.core.config import settings
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str:
"""SVG диапазон-бара как data URI. WeasyPrint надёжно рендерит SVG (как QR-код)."""
width, height = 600, 32
x1 = width * q_low
x2 = width * q_high
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {width} {height}" preserveAspectRatio="none">'
f'<rect x="0.5" y="0.5" width="{width-1}" height="{height-1}" '
f'fill="#ffffff" stroke="#9ca3af" stroke-width="1"/>'
f'<rect x="{x1}" y="1" width="{x2-x1}" height="{height-2}" fill="#dc2626"/>'
f'</svg>'
)
return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}"
logger = logging.getLogger(__name__)
# ── URL allowlist (C-6 security audit) ─────────────────────────────────────
# Только доверенные домены-источники объявлений попадают в <a href=> в PDF.
# Защита от javascript: / data: инъекций через source_url объявлений.
_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset({
"www.avito.ru", "avito.ru", "m.avito.ru",
"www.cian.ru", "cian.ru",
"realty.yandex.ru",
"n1.ru", "ekaterinburg.n1.ru",
})
# CDN-домены для изображений аналогов (photo_url).
_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset({
"images.avito.st",
"cdn-p.cian.site",
"avatars.mds.yandex.net",
"n1ru.cdn-cw.com",
})
def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
"""Проверяет URL по allowlist схемы и домена.
cdn=False — allowlist для source_url объявлений (ссылки на листинги).
cdn=True — allowlist для photo_url (CDN изображений).
Возвращает None если URL не прошёл проверку — embed/ссылка пропускается.
"""
if not url:
return None
try:
p = urlparse(url)
except Exception:
return None
if p.scheme != "https":
return None
allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS
if p.netloc not in allowed:
return None
return url
# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ──
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
"cian": ("#0468ff", "#fff"), # Циан фирменный синий
"domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер
"yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный
"n1": ("#ff6b00", "#fff"), # N1 оранжевый
"rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"n1": "N1",
"rosreestr": "Росреестр",
"etazhi": "Этажи",
}
def _source_logo_pill(source: str) -> str:
"""Pill-badge с цветом источника (заменяет реальные логотипы Брусники)."""
bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"<span style='display:inline-block;padding:3pt 8pt;background:{bg};color:{fg};"
f"font-size:8pt;font-weight:700;border-radius:3pt;margin-right:4pt;"
f"vertical-align:middle;'>{_html.escape(name)}</span>"
)
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
if not source:
return "<span style='color:#9ca3af;'>—</span>"
bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
f"font-size:6.5pt;font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
def _fmt_rub(value: int) -> str:
"""12 500 000 ₽"""
return f"{value:,}".replace(",", " ") + ""
def _fmt_rub_m(value: int) -> str:
"""3.4 млн. руб."""
m = value / 1_000_000
return f"{m:.1f}".replace(".", ",") + " млн. руб."
def _fmt_ppm2(value: int) -> str:
"""101 176"""
return f"{value:,}".replace(",", " ")
def _report_number(estimate_id: UUID) -> str:
"""Брусника-style № отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
short = int(estimate_id.int) % 10_000
long = int(estimate_id.int) % 10_000_000_000
return f"EKБ-{short:04d}-{long:010d}"
def _qr_code_data_url(text: str, size: int = 4) -> str:
"""QR-код как SVG data URL."""
qr = segno.make(text, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
# ── Price range SVG bar (как у Брусники) ────────────────────────────────────
_BAR_PNG_URL = _bar_svg_data_url()
def _price_range_bar(
range_low: int,
range_high: int,
*,
label_left: str | None = None,
label_right: str | None = None,
sub_label: str = "Рынок",
days_min: int | None = None,
days_max: int | None = None,
show_days: bool = False,
) -> str:
"""Полоска диапазона цен в стиле Брусники — PNG через Pillow."""
if range_high <= range_low:
return ""
label_left = label_left or _fmt_rub_m(range_low)
label_right = label_right or _fmt_rub_m(range_high)
days_overlay = ""
if show_days and days_min is not None and days_max is not None:
days_overlay = (
f'<table style="width:100%;margin-top:4pt;">'
f'<tr>'
f'<td style="width:20%;"></td>'
f'<td style="width:60%;text-align:left;font-size:9pt;color:#dc2626;font-weight:700;">'
f'{days_min} дней'
f'</td>'
f'<td style="width:20%;text-align:right;font-size:9pt;color:#dc2626;font-weight:700;">'
f'{days_max} дней'
f'</td>'
f'</tr></table>'
)
# Ц-таблица: 3 ячейки фиксированной ширины с background-color на td.
# Высота через padding (WeasyPrint stable с padding на td).
days_row = ""
if show_days and days_min is not None and days_max is not None:
days_row = (
f'<tr><td></td>'
f'<td style="text-align:left;font-size:8pt;color:#dc2626;font-weight:700;padding-top:3pt;">'
f'{days_min} дней</td>'
f'<td style="text-align:right;font-size:8pt;color:#dc2626;font-weight:700;padding-top:3pt;">'
f'{days_max} дней</td>'
f'<td></td></tr>'
)
return (
f'<table style="width:100%;border-collapse:collapse;margin:6pt 0;">'
f'<tr><td colspan="4" style="text-align:center;font-size:9pt;color:#6b7280;padding-bottom:3pt;">'
f'{_html.escape(sub_label)}'
f'</td></tr>'
f'<tr style="line-height:0;">'
f'<td style="width:1%;border:1pt solid #9ca3af;border-right:none;'
f'background:#ffffff;padding:6pt 0;"></td>'
f'<td style="width:60%;border-top:1pt solid #9ca3af;border-bottom:1pt solid #9ca3af;'
f'background:#dc2626;padding:6pt 0;"></td>'
f'<td style="width:1%;border:1pt solid #9ca3af;border-left:none;'
f'background:#ffffff;padding:6pt 0;"></td>'
f'<td style="width:38%;background:transparent;padding:6pt 0;"></td>'
f'</tr>'
f'<tr><td colspan="2" style="text-align:left;font-size:10pt;font-weight:700;color:#374151;padding-top:3pt;">'
f'{_html.escape(label_left)}</td>'
f'<td colspan="2" style="text-align:right;font-size:10pt;font-weight:700;color:#374151;padding-top:3pt;">'
f'{_html.escape(label_right)}</td></tr>'
f'{days_row}'
f'</table>'
)
# ── Page 1: Cover ────────────────────────────────────────────────────────────
def _build_cover(
estimate: AggregatedEstimate, input_snapshot: dict, brand
) -> str: # type: ignore[no-untyped-def,type-arg]
today = dt.date.today()
expires = today + dt.timedelta(days=30)
report_num = _report_number(estimate.estimate_id)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
address_short = full_address.split(",")[0:3]
address_short = ", ".join(s.strip() for s in address_short)
address = _html.escape(address_short or full_address)
area = input_snapshot.get("area_m2", 0)
rooms = input_snapshot.get("rooms", 0)
floor = input_snapshot.get("floor", 0)
total_floors = 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")
house_labels = {
"panel": "Панельный",
"brick": "Кирпичный",
"monolith": "Монолитный",
"monolith_brick": "Монолит-кирпич",
"other": "Другое",
}
repair_labels = {
"needs_repair": "Требуется ремонт",
"standard": "Стандартный",
"good": "Хороший",
"excellent": "Евроремонт",
}
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "")
house_label = house_labels.get(house_type, "") if house_type else ""
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else ""
# Active market subband — 4-118 days range (как у Брусники).
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
days_min, days_max = _days_on_market_range(estimate.analogs)
# Deals range — если deals есть, считаем; иначе fallback к listings range
deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub))
listings_bar = _price_range_bar(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Активный рынок с аналогичным состоянием ремонта",
days_min=days_min,
days_max=days_max,
show_days=True,
)
deals_bar = _price_range_bar(
deals_low,
deals_high,
sub_label="Диапазон цен по фактическим сделкам",
)
qr_url = _qr_code_data_url(
f"{settings.public_url}?id={estimate.estimate_id}", size=3
)
return f"""
<div style="page-break-after:always;">
<!-- Top brand bar — компактный -->
<div style="background:{brand.primary_color};color:#fff;padding:8pt 14pt;margin-bottom:8pt;
font-size:12pt;font-weight:900;letter-spacing:0.06em;">
{_html.escape(brand.name).upper()}
</div>
<h1 style="font-size:12pt;font-weight:700;margin:4pt 0 8pt 0;text-transform:uppercase;
letter-spacing:0.02em;line-height:1.25;">
АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
</h1>
<!-- Параметры квартиры — table как у Брусники -->
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr><td>№ отчета</td><td class="bold">{report_num}</td></tr>
<tr><td>Дата отчета</td><td class="bold">{today.strftime("%d.%m.%Y")}</td></tr>
<tr><td>Срок действия данных</td><td class="bold">до {expires.strftime("%d.%m.%Y")}</td></tr>
<tr><td>Адрес</td><td class="bold">{address}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_built}</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Этаж / этажность</td><td class="bold">{floor} / {total_floors}</td></tr>
<tr><td>Площадь</td><td class="bold">{area} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">
Диапазон цен в объявлениях
<span style="font-weight:400;font-size:9pt;color:#6b7280;">(без учета ремонта)</span>
</p>
{listings_bar}
<p style="margin:10pt 0 4pt 0;font-size:10pt;font-weight:700;">
Диапазон цен по фактическим сделкам
</p>
{deals_bar}
<!-- Что важно при оценке -->
<h3 style="margin-top:10pt;font-size:10pt;font-weight:700;">
Что важно при оценке стоимости квартиры:
</h3>
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:8pt;">
<tr>
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 512% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости</td>
</tr>
<tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего на 150 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
</tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table>
<p style="margin-top:18pt;font-size:8pt;color:#6b7280;border-top:1pt solid #e6e8ec;padding-top:8pt;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
</p>
</div>
"""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
"""Min/max days_on_market по аналогам. Fallback 4-118 как у Брусники."""
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
if not days:
return 4, 118
return min(days), max(days)
def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]:
"""Min/max price от deals. Fallback к listings range."""
if not deals:
return fallback
prices = [d.price_rub for d in deals]
return min(prices), max(prices)
# ── Page 2: Listings (market) ────────────────────────────────────────────────
def _build_listings_page(
estimate: AggregatedEstimate, input_snapshot: dict, brand
) -> str: # type: ignore[no-untyped-def,type-arg]
n_total = estimate.n_analogs
n_with_repair = max(3, int(n_total * 0.4)) # упрощение: ~40% с указанным ремонтом
# Source logos (pseudo) — 5 максимальных
sources_to_show = estimate.sources_used or ["avito", "cian", "domklik", "yandex", "rosreestr"]
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
area = float(input_snapshot.get("area_m2", 0) or 0)
area_min = round(area * 0.85, 1)
area_max = round(area * 1.15, 1)
rooms = input_snapshot.get("rooms", 0)
year_built = input_snapshot.get("year_built")
year_range = f"{year_built - 2}-{year_built + 2}" if year_built else ""
house_type = input_snapshot.get("house_type")
house_label = {
"panel": "Эконом до 2000 года",
"brick": "Кирпич до 2000",
"monolith": "Современный (монолит)",
"monolith_brick": "Современный (м-к)",
"other": "Любой",
}.get(house_type, "Любой") if house_type else "Любой"
repair_state = input_snapshot.get("repair_state")
repair_label = {
"needs_repair": "Требуется ремонт",
"standard": "Стандартный",
"good": "Хороший",
"excellent": "Евроремонт",
}.get(repair_state, "Любой") if repair_state else "Любой"
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
# Полоска диапазона
days_min, days_max = _days_on_market_range(estimate.analogs)
range_bar = _price_range_bar(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Рынок",
days_min=days_min,
days_max=days_max,
show_days=True,
)
# Топ-5 примеров (отсортированных по distance)
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5)
return f"""
<div style="page-break-after:always;">
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{_html.escape(brand.name).upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
</h2>
<!-- Два колонки: левая (counts + источники), правая (params) -->
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr>
<td style="width:55%;vertical-align:top;padding-right:14pt;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
<td class="bold" style="text-align:right;">{n_total} шт.</td></tr>
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам<br>
<span style="font-size:8pt;color:#6b7280;">(с учётом ремонта)</span></td>
<td class="bold" style="text-align:right;">{n_with_repair} шт.</td></tr>
</table>
<div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
<div style="margin-top:6pt;">{sources_html}</div>
</td>
<td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
<table class="search-params" style="width:100%;border-collapse:collapse;">
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
<tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
</table>
</td>
</tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">Диапазон цен в объявлениях</p>
{range_bar}
<!-- Примеры аналогов -->
<h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
Примеры аналогичных объектов с учётом ремонта
</h3>
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Адрес квартиры</th>
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Источник</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость 1 м², руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость объекта, руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Срок экспозиции, дней</th>
</tr>
</thead>
<tbody>{examples_rows}</tbody>
</table>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2
</div>
</div>
"""
def _examples_rows(lots: list[AnalogLot]) -> str:
if not lots:
return "<tr><td colspan='5' class='empty' style='text-align:center;padding:14pt;color:#9ca3af;'>Нет данных</td></tr>"
rows = []
for lot in lots:
addr = _html.escape(lot.address)
safe = _safe_url(lot.source_url)
if safe:
addr_cell = f"<a href='{_html.escape(safe)}' style='color:#1d4ed8;text-decoration:none;'>{addr} ↗</a>"
else:
addr_cell = addr
rows.append(
"<tr>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{addr_cell}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{_source_badge_inline(lot.source)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{_fmt_ppm2(lot.price_per_m2)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{_fmt_ppm2(lot.price_rub)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{lot.days_on_market or ''}</td>"
"</tr>"
)
return "".join(rows)
# ── Page 3: Deals ────────────────────────────────────────────────────────────
def _build_deals_page(
estimate: AggregatedEstimate, input_snapshot: dict, brand
) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# Источники для сделок — Этажи / Домклик / Росреестр
deal_sources = ["etazhi", "domklik", "rosreestr"]
sources_html = "".join(_source_logo_pill(s) for s in deal_sources)
area = float(input_snapshot.get("area_m2", 0) or 0)
area_min = round(area * 0.85, 1)
area_max = round(area * 1.15, 1)
rooms = input_snapshot.get("rooms", 0)
year_built = input_snapshot.get("year_built")
year_range = f"{year_built - 2}{year_built + 2}" if year_built else ""
house_type = input_snapshot.get("house_type")
house_label = {
"panel": "Эконом до 2000 года",
"brick": "Кирпич до 2000",
"monolith": "Современный (монолит)",
}.get(house_type, "Любой") if house_type else "Любой"
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub))
days_min, days_max = _days_on_market_range(estimate.actual_deals)
range_bar = _price_range_bar(
deals_low, deals_high, sub_label="Рынок",
days_min=days_min, days_max=days_max, show_days=True,
)
top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5)
return f"""
<div style="page-break-after:always;">
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{_html.escape(brand.name).upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ
</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr>
<td style="width:55%;vertical-align:top;padding-right:14pt;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{n_deals} шт.</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td>
<td class="bold" style="text-align:right;">{period_start.strftime("%m.%Y")} {today.strftime("%m.%Y")}</td></tr>
</table>
<div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
<div style="margin-top:6pt;">{sources_html}</div>
</td>
<td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
<table class="search-params" style="width:100%;border-collapse:collapse;">
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
<tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
</table>
</td>
</tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">Диапазон цен по фактическим сделкам</p>
{range_bar}
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 512 % дешевле,
чем заявлено в объявлениях
</div>
<h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
Примеры аналогичных объектов
</h3>
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Адрес квартиры</th>
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Источник</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость 1 м², руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость объекта, руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Срок экспозиции, дней</th>
</tr>
</thead>
<tbody>{examples_rows}</tbody>
</table>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3
</div>
</div>
"""
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
def _build_offer_page(
estimate: AggregatedEstimate, brand
) -> str: # type: ignore[no-untyped-def]
median = estimate.median_price_rub
# Расчёт расходов как у Брусники: на основе медианы
listing_price = median # цена в объявлении
torg_pct_low, torg_pct_high = 5, 15
torg_low = int(listing_price * torg_pct_low / 100)
torg_high = int(listing_price * torg_pct_high / 100)
# После торга — средний по диапазону для остальных %
sold_price = listing_price - int(listing_price * 0.10) # ~10% торг
rieltor_pct_low, rieltor_pct_high = 2, 3
rieltor_low = int(sold_price * rieltor_pct_low / 100)
rieltor_high = int(sold_price * rieltor_pct_high / 100)
rent_low, rent_high = 90_000, 150_000 # 3 месяца аренды (фикс)
juridical_low = 19_250 # фикс
ads_low, ads_high = 4_000, 36_000 # за 3 месяца
total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
total_high = torg_high + rieltor_high + rent_high + juridical_low + ads_high
brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
return f"""
<div><!-- last page: NO page-break-after -->
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{brand_short.upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:14pt;">
ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
</h2>
<h3 style="font-size:11pt;font-weight:700;margin-bottom:8pt;">Структура стоимости сделки</h3>
<table class="offer-table" style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;"></th>
<th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">
Продажа через<br>{trade_in_label}
</th>
<th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">
Самостоятельная продажа, руб.
</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Торг потенциальных покупателей</div>
<div style="font-size:8pt;color:#6b7280;">при стоимости квартиры в объявлении {_fmt_rub(listing_price)}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#6b7280;">Не применимо</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;color:#dc2626;">
от {_fmt_rub(torg_low)} {_fmt_rub(torg_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">от {torg_pct_low}{torg_pct_high}%</span>
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Услуги риэлтора при продаже квартиры</div>
<div style="font-size:8pt;color:#6b7280;">с минимальным торгом за {_fmt_rub(sold_price)}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
{_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">от {rieltor_pct_low}{rieltor_pct_high}%</span>
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Аренда после сделки</div>
<div style="font-size:8pt;color:#6b7280;">однокомнатной квартиры на 3 месяца</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
{_fmt_rub(rent_low)} - {_fmt_rub(rent_high)}
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Юридическое сопровождение</div>
<div style="font-size:8pt;color:#6b7280;">Проверка документов и подготовка договоров</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">от {_fmt_rub(juridical_low)}</td>
</tr>
<tr style="border-bottom:2pt solid #e6e8ec;">
<td style="padding:6pt 4pt;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:8pt;color:#6b7280;">Ежемесячное базовой продвижение объекта на Циан, Авито, Я.Недвижимости</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">за 3 месяца</span>
</td>
</tr>
<tr style="background:#fef3c7;">
<td style="padding:8pt 4pt;font-size:11pt;font-weight:700;">Общие финансовые потери</td>
<td style="padding:8pt 4pt;text-align:right;"></td>
<td style="padding:8pt 4pt;text-align:right;font-size:11pt;font-weight:700;color:#7f1d1d;">
от {_fmt_rub(total_low)} - {_fmt_rub(total_high)}
</td>
</tr>
</tbody>
</table>
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
самостоятельной продаже квартиры
</div>
<h3 style="margin-top:18pt;font-size:11pt;font-weight:700;">Преимущества</h3>
<div class="advantages" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;
gap:8pt;margin-top:8pt;">
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>
</svg>
</div>
<div class="adv-title">Экономия времени</div>
<div class="adv-desc">Берём на себя показы, переговоры и поиск покупателей</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<div class="adv-title">Юридическая безопасность</div>
<div class="adv-desc">Проверяем документы, исключаем риски, сопровождаем на всех этапах</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>
</svg>
</div>
<div class="adv-title">Фиксированная стоимость новостройки</div>
<div class="adv-desc">Сохраняем цену выбранной планировки в {brand_short}</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>
</svg>
</div>
<div class="adv-title">Гарантия цены</div>
<div class="adv-desc">Снимаем угрозу колебаний рынка и удерживаем договорную стоимость</div>
</div>
</div>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 4 · Расчёт носит ориентировочный характер и не является офертой.
</div>
</div>
"""
# ── CSS ──────────────────────────────────────────────────────────────────────
def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
primary = brand.primary_color if brand else "#1d4ed8"
return f"""
@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;
line-height: 1.4;
}}
h1, h2, h3 {{ margin: 0; }}
.page {{ page-break-after: always; position: relative; }}
.page:last-child {{ page-break-after: avoid; }}
.bold {{ font-weight: 700; }}
/* Params table (Cover) */
.params-table td {{
padding: 2.5pt 4pt;
border-bottom: 1px solid #f3f4f6;
font-size: 9pt;
}}
.params-table td:first-child {{
color: #6b7280;
width: 40%;
}}
.params-table td:last-child {{
text-align: right;
}}
/* Search params (Listings/Deals) */
.search-params td {{
padding: 4pt 0;
font-size: 9pt;
}}
.search-params td:first-child {{
color: #6b7280;
}}
.search-params td:last-child {{
text-align: right;
}}
/* Range block */
.range-block {{
margin-top: 6pt;
}}
/* Range bar — linear-gradient на одном div'е. WeasyPrint reliable. */
.range-bar-strip {{
width: 100%;
height: 16pt;
line-height: 14pt;
border: 1pt solid #9ca3af;
background: linear-gradient(
to right,
#ffffff 0%, #ffffff 20%,
#dc2626 20%, #dc2626 80%,
#ffffff 80%, #ffffff 100%
);
margin: 4pt 0;
overflow: hidden;
color: transparent;
font-size: 1pt;
}}
.range-title {{
font-size: 10pt;
font-weight: 700;
color: #1a1d23;
margin-bottom: 4pt;
}}
/* Что важно — advice table */
.advice-table td {{
padding: 5pt 6pt;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
font-size: 8.5pt;
line-height: 1.3;
}}
.advice-title {{
font-weight: 700;
width: 30%;
color: #1a1d23;
}}
.advice-text {{
color: #4b5563;
}}
/* Advantages (Offer) */
.advantage-item {{
border: 1px solid #e6e8ec;
border-radius: 4pt;
padding: 8pt;
text-align: left;
}}
.advantage-item .adv-ic {{
display: block;
width: 28pt;
height: 28pt;
border-radius: 50%;
background: #fef2f2;
margin: 0 0 6pt 0;
line-height: 28pt;
text-align: center;
}}
.advantage-item .adv-ic svg {{
vertical-align: middle;
}}
.advantage-item .adv-title {{
font-weight: 700;
font-size: 8.5pt;
margin-bottom: 3pt;
color: {primary};
letter-spacing: -0.01em;
}}
.advantage-item .adv-desc {{
font-size: 7pt;
color: #5b6066;
line-height: 1.3;
}}
"""
# ── Public API ───────────────────────────────────────────────────────────────
def generate_trade_in_pdf(
estimate: AggregatedEstimate,
input_snapshot: dict, # type: ignore[type-arg]
*,
brand=None, # type: ignore[no-untyped-def]
) -> bytes:
"""Генерирует 4-страничный WeasyPrint PDF в стиле Брусника.Обмен.
Pages:
1. Cover — № отчёта + параметры + 2 диапазона + 3 блока «Что важно»
2. Listings — рынок аналогов + источники + таблица примеров
3. Deals — фактические сделки + источники + таблица
4. Offer — Trade-In vs самопродажа + 4 преимущества
Args:
estimate: AggregatedEstimate из БД
input_snapshot: ввод пользователя (address, area_m2, ...)
brand: optional Brand для white-label
Returns:
PDF bytes для Response(media_type="application/pdf")
"""
from app.services.brand import Brand
if brand is None:
brand = Brand(
slug="generic",
name="Trade-In",
logo_url=None,
primary_color="#1d4ed8",
accent_color="#f59e0b",
footer_text=None,
pdf_disclaimer=None,
)
html_str = (
'<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">'
f'<title>Trade-In — {_html.escape(input_snapshot.get("address", ""))}</title>'
"</head><body>"
+ _build_cover(estimate, input_snapshot, brand)
+ _build_listings_page(estimate, input_snapshot, brand)
+ _build_deals_page(estimate, input_snapshot, brand)
+ _build_offer_page(estimate, brand)
+ "</body></html>"
)
css_str = _build_css(brand=brand)
# base_url=file:/// чтобы WeasyPrint мог resolve file:// URLs для PNG
pdf_bytes = HTML(string=html_str, base_url="file:///").write_pdf(stylesheets=[CSS(string=css_str)])
logger.info(
"PDF generated estimate_id=%s brand=%s size=%d (Brusnika-style)",
estimate.estimate_id, brand.slug, len(pdf_bytes),
)
return pdf_bytes