gendesign/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
Light1YT 870f062587
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 43s
Deploy Trade-In / deploy (push) Successful in 35s
feat(tradein): expected-sold price in estimate PDF (#648 S5b — final) (#665)
2026-05-29 14:09:46 +00:00

1149 lines
52 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 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>'
)
# ── Dual price headline: «Ориентир запроса» + «Ожидаемая цена продажи» ───────
# Зеркалит web HeroSummary (#648 S5a): две равновесные цифры. Asking — медиана
# по объявлениям; expected_sold — реалистичная цена сделки (asking × ratio из
# asking_to_sold_ratios, migration 080). Graceful: при отсутствии sold (None/0 —
# старые оценки / пустой бакет ratio-таблицы) рисуем ТОЛЬКО asking-колонку.
def _discount_pct(estimate: AggregatedEstimate) -> int | None:
"""Скидка запрос→продажа в % (как web): из asking_to_sold_ratio при наличии,
иначе из самих чисел sold/median. None если sold нет или скидка < 1%."""
sold = estimate.expected_sold_price_rub
if not sold or sold <= 0:
return None
ratio = estimate.asking_to_sold_ratio
if ratio is not None and ratio > 0:
pct = round((1 - ratio) * 100)
elif estimate.median_price_rub > 0:
pct = round((1 - sold / estimate.median_price_rub) * 100)
else:
return None
return pct if pct >= 1 else None
def _price_figure_cell(
*,
label: str,
value_rub: int,
range_low: int | None,
range_high: int | None,
per_m2: int | None,
accent: str,
delta_pct: int | None = None,
) -> str:
"""Одна ценовая колонка (label + крупное число + диапазон + ₽/м²).
Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении —
как web (guard на typeof number). delta_pct — опциональный chip «N%»."""
delta_chip = ""
if delta_pct is not None:
delta_chip = (
f"<span style='display:inline-block;margin-left:6pt;padding:1pt 6pt;"
f"background:{accent};color:#fff;font-size:8pt;font-weight:700;"
f"border-radius:8pt;vertical-align:middle;'>&#8722;{delta_pct}%</span>"
)
range_html = ""
if range_low is not None and range_high is not None and range_high > range_low:
range_html = (
f"<div style='font-size:10pt;color:#6b7280;font-weight:600;margin-top:3pt;"
f"font-variant-numeric:tabular-nums;'>"
f"{_html.escape(_fmt_rub_m(range_low))} &#8211; {_html.escape(_fmt_rub_m(range_high))}"
f"</div>"
)
perm2_html = ""
if per_m2 is not None and per_m2 > 0:
perm2_html = (
f"<div style='font-size:9pt;color:#9ca3af;margin-top:2pt;"
f"font-variant-numeric:tabular-nums;'>{_fmt_ppm2(per_m2)} &#8381;/м²</div>"
)
return (
f"<td style='width:50%;vertical-align:top;padding:10pt 12pt;background:#f9fafb;"
f"border-top:2pt solid {accent};border-radius:4pt;'>"
f"<div style='font-size:9pt;color:#6b7280;font-weight:700;text-transform:uppercase;"
f"letter-spacing:0.03em;'>{_html.escape(label)}{delta_chip}</div>"
f"<div style='font-size:18pt;font-weight:900;color:#1a1d23;margin-top:4pt;"
f"font-variant-numeric:tabular-nums;'>{_html.escape(_fmt_rub_m(value_rub))}</div>"
f"{range_html}{perm2_html}"
f"</td>"
)
def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
"""Две равновесные цены: «Ориентир запроса» и «Ожидаемая цена продажи».
Graceful: если expected_sold_price_rub None/0 — одиночная колонка (asking),
explainer/chip скрыты (текущее поведение PDF для старых оценок)."""
accent = brand.primary_color if brand else "#1d4ed8"
sold = estimate.expected_sold_price_rub
has_sold = bool(sold) and sold > 0
asking_cell = _price_figure_cell(
label="Ориентир запроса",
value_rub=estimate.median_price_rub,
range_low=estimate.range_low_rub,
range_high=estimate.range_high_rub,
per_m2=estimate.median_price_per_m2,
accent=accent,
)
if not has_sold:
# Одиночная колонка — растягиваем на всю ширину (graceful fallback).
single = asking_cell.replace("width:50%", "width:100%", 1)
return (
f'<table style="width:100%;border-collapse:separate;border-spacing:0;'
f'margin:8pt 0 4pt 0;"><tr>{single}</tr></table>'
)
sold_cell = _price_figure_cell(
label="Ожидаемая цена продажи",
value_rub=sold,
range_low=estimate.expected_sold_range_low_rub,
range_high=estimate.expected_sold_range_high_rub,
per_m2=estimate.expected_sold_per_m2,
accent="#dc2626",
delta_pct=_discount_pct(estimate),
)
explainer = (
'<p style="margin:6pt 0 4pt 0;font-size:8.5pt;color:#6b7280;line-height:1.4;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса."
"</p>"
)
return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
f'margin:8pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f"{explainer}"
)
# ── 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") or ""
total_floors = input_snapshot.get("total_floors") or ""
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>
<!-- Две равновесные цены: ориентир запроса vs ожидаемая цена продажи (#648 S5b) -->
{_dual_price_block(estimate, brand)}
<p style="margin:10pt 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")
"""
# WeasyPrint импортируется лениво: его native-зависимости (pango/cairo) нужны
# только для финального write_pdf — HTML-билдеры (cover/listings/...) работают
# без них, что делает рендер-функции тестируемыми в окружениях без native libs.
from weasyprint import CSS, HTML
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