diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index a5580bbc..539b0ebe 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1,6 +1,13 @@
-"""PDF генератор для Trade-In Estimator — Брусника-style.
+"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera.
-Точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf.
+Дизайн опирается на живой веб-продукт (tradein-mvp/frontend/.../trade-in.css):
+цветовая палитра сконвертирована из OKLCH-токенов веб-приложения в hex через
+точную OKLab→sRGB матрицу (Ottosson), типографика — DejaVu Serif (заголовки
+страниц), DejaVu Sans (текст/таблицы), DejaVu Sans Mono (все числовые значения,
+tabular-nums) — три семейства из пакета fonts-dejavu-core, гарантированно
+установленного в Docker-образе (без внешних зависимостей и риска font-embedding).
+Документ — деловой/утилитарный отчёт (ближе к банковской оценке, чем к
+маркетинговому лендингу), НЕ 1:1 копия конкурентного референса.
Структура отчёта — 4 страницы:
1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
@@ -9,7 +16,7 @@
4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества
White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
-Расширения над Брусникой (сохранены): source badges + дистанция + кликабельные ссылки + QR-код.
+Расширения: source badges + дистанция + кликабельные ссылки.
"""
from __future__ import annotations
@@ -28,23 +35,6 @@ import segno
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'"
- )
- return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}"
-
-
logger = logging.getLogger(__name__)
@@ -172,10 +162,41 @@ def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
return fetcher
-# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ──
+# ── Design tokens (design system) ────────────────────────────────────────────
+# Цвета сконвертированы из OKLCH-токенов живого веб-продукта
+# (tradein-mvp/frontend/src/components/trade-in/trade-in.css) точной OKLab→sRGB
+# матрицей (Ottosson reference), а не приблизительно на глаз. --muted/--success
+# в исходнике веба уже hex — взяты как есть, без конверсии.
+_FG = "#0e1217" # --fg: oklch(18% 0.012 250) — основной текст/чернила
+_BG = "#fbfcfd" # --bg: oklch(99% 0.002 240) — чуть холодный near-white, не чистый #fff
+_MUTED = "#5f6570" # --muted (web, уже hex) — вторичный текст/лейблы
+_ACCENT = "#1b6ad4" # --accent: oklch(54% 0.18 258) — базовый синий (non-white-label)
+_ACCENT_SOFT = "#e6f3ff" # oklch(96% 0.03 258)
+_ACCENT_2 = "#da6600" # --accent-2: oklch(64% 0.17 50) — тёплый вторичный (был generic amber)
+_ACCENT_2_SOFT = "#ffedce" # oklch(96% 0.05 70)
+_DANGER = "#d02b31" # --danger: oklch(56% 0.20 25) — был generic bootstrap-red #dc2626
+_DANGER_SOFT = "#ffe8e4" # oklch(96% 0.04 25)
+_SUCCESS = "#197038" # --success (web, уже hex) — «бесплатно» / экономия
+_BORDER = "#e2e5e8" # --border: oklch(92% 0.005 250) — hairline (был произвольный #e6e8ec)
+_BORDER_STRONG = "#babec3" # --border-strong: oklch(80% 0.008 250)
+_CARD_BG = "#f4f7f9" # --surface-2: oklch(97.5% 0.004 240) — карточка (был #f9fafb)
+
+# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
+# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
+_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
+_FS_SM = "9pt" # labels, заголовки таблиц, explainer-текст
+_FS_MD = "10.5pt" # базовый текст (body), значения в таблицах
+_FS_LG = "13pt" # заголовки страниц (h2, DejaVu Serif)
+_FS_XL = "16pt" # главный заголовок cover (h1, DejaVu Serif)
+_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
+
+
+# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
-# исторических строках НЕ падает.
+# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
+# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
+# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
"cian": ("#0468ff", "#fff"), # Циан фирменный синий
@@ -196,12 +217,12 @@ _SOURCE_DISPLAY_NAMES: dict[str, str] = {
def _source_logo_pill(source: str) -> str:
- """Pill-badge с цветом источника (заменяет реальные логотипы Брусники)."""
- bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
+ """Pill-badge с цветом источника."""
+ bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"{_html.escape(name)}"
)
@@ -209,21 +230,25 @@ def _source_logo_pill(source: str) -> str:
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
if not source:
- return "—"
- bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
+ return f"—"
+ bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"{_html.escape(name)}"
+ f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
+_NBSP = "\u00a0" # неразрывный пробел — разряды/валюта не переносятся посередине
+
+
def _fmt_rub(value: int) -> str:
- """12 500 000 ₽"""
- return f"{value:,}".replace(",", " ") + " ₽"
+ """12 500 000 ₽ — неразрывные пробелы, чтобы число никогда не переносилось
+ посередине (разряды/символ валюты не должны сиротеть на разных строках)."""
+ return f"{value:,}".replace(",", _NBSP) + _NBSP + "₽"
def _fmt_rub_m(value: int) -> str:
@@ -237,8 +262,15 @@ def _fmt_ppm2(value: int) -> str:
return f"{value:,}".replace(",", " ")
+def _mono(text: str) -> str:
+ """Оборачивает числовой текст (цены/даты/%/№ отчёта/м²/₽-м²/срок экспозиции)
+ в моноширинный span — DejaVu Sans Mono + tabular-nums (см. .mono в _build_css),
+ зеркалит web-конвенцию .num/.mono из trade-in.css (HeroSummary.tsx и др.)."""
+ return f"{text}"
+
+
def _report_number(estimate_id: UUID) -> str:
- """Брусника-style № отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
+ """№ отчёта: '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}"
@@ -256,10 +288,36 @@ def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
-# ── Price range SVG bar (как у Брусники) ────────────────────────────────────
+# ── Unified page header (cover + pages 2-4 использовали визуально разные
+# паттерны — сплошная цветная плашка vs текстовая полоска; теперь один блок) ──
-_BAR_PNG_URL = _bar_svg_data_url()
+def _page_header(brand) -> str: # type: ignore[no-untyped-def]
+ """Логотип/имя бренда + акцентная линия снизу — единый паттерн для всех 4 страниц.
+
+ brand.primary_color по-прежнему управляет цветом линии/плашки (white-label
+ механизм не тронут) — только markup унифицирован.
+ """
+ logo_url = _safe_logo_url(brand.logo_url)
+ mark_html = (
+ f""
+ if logo_url
+ else f"{_html.escape(brand.name).upper()}"
+ )
+ return (
+ f'
| ' - f"{_html.escape(sub_label)}" - f" | |||
| ' - f' | ' - f' | ' - f' | ' - f" |
| ' - f"{_html.escape(label_left)} | " - f'' - f"{_html.escape(label_right)} | ||
' + f'
' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." @@ -437,7 +492,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno return ( f'
" + f"
" f"{_html.escape(brand.pdf_disclaimer)}
" ) return f"""| № отчета | {report_num} | ||||
| Дата отчета | {today.strftime("%d.%m.%Y")} | ||||
| Срок действия данных | до {expires.strftime("%d.%m.%Y")} |
| № отчета | {_mono(report_num)} | ||||
| Дата отчета | {_mono(today.strftime("%d.%m.%Y"))} | ||||
| Срок действия данных | +до {_mono(expires.strftime("%d.%m.%Y"))} | ||||
| Адрес | {address} | ||||
| Год постройки | {year_built} | ||||
| Год постройки | {_mono(str(year_built))} | ||||
| Тип дома | {house_label} | ||||
| Этаж / этажность | {floor} / {total_floors} | ||||
| Площадь | {area} м² | ||||
| Этаж / этажность | {_mono(f"{floor} / {total_floors}")} | ||||
| Площадь | {_mono(f"{area} м²")} | ||||
| Планировка | {rooms_label} | ||||
| Состояние ремонта | {repair_label} | ||||
| Балкон / лоджия | {balcony_label} |
| Цены в объявлениях — ожидания собственников | -Фактические сделки проходят на 5–12% ниже, что подтверждают - Росреестр, ДомКлик и продажи агенств недвижимости | +Фактические сделки проходят на 10–18% ниже, что подтверждают + Росреестр, ДомКлик и продажи агентств недвижимости |
| Ремонт оценивается по состоянию, а не по вложенным суммам | @@ -596,8 +641,8 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
+
Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}
{disclaimer_html} @@ -607,7 +652,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]: - """Min/max days_on_market по аналогам. Fallback 4-118 как у Брусники.""" + """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 @@ -686,15 +731,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra return f"""| Количество объявлений по аналогичным объектам | -{n_total} шт. | {_mono(f"{n_total} шт.")} |
| Расположение | ± 1 км от локации дома |
| Тип дома | {house_label} |
| Год постройки | {year_range} |
| Диапазон площади | {area_min} - {area_max} м² |
| Год постройки | {_mono(year_range)} |
| Диапазон площади | +{_mono(f"{area_min} - {area_max} м²")} |
| Планировка | {rooms_label} |
| Состояние ремонта | {repair_label} |
Диапазон цен в объявлениях
++ Диапазон цен в объявлениях
{range_bar} -| + | Адрес квартиры | -Источник | -Стоимость 1 м², руб. | -Стоимость объекта, руб. | -Срок экспозиции, дней |
|---|
+
Диапазон цен по фактическим сделкам
{range_bar} -| + | Адрес квартиры | -Источник | -Стоимость 1 м², руб. | -Стоимость объекта, руб. | -Срок экспозиции, дней |
|---|