1149 lines
52 KiB
Python
1149 lines
52 KiB
Python
"""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;'>−{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))} – {_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)} ₽/м²</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">Фактические сделки проходят на 5–12% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости</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;">
|
||
По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле,
|
||
чем заявлено в объявлениях
|
||
</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
|