From 13082653ec3536991672cbcccac65f0440347d71 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 00:37:58 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/pdf):=20offer-=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D1=81=D1=8F=20=D1=81=20cover=20+=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BF=D1=83=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20+=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer-страница PDF-отчёта считала выкупную цену как медиана-10% (плоский хайркат), игнорируя estimate.expected_sold_price_rub — ту же реальную цифру по ДКП Росреестра, которую cover-страница уже показывает как «Ожидаемая цена продажи». Из-за этого страницы 1 и 4 одного отчёта могли показывать разные цифры (расхождение 10%+ на реальных данных). Теперь offer использует expected_sold_price_rub с fallback на старую эвристику для старых оценок без sold-данных. Заодно: актуализированы захардкоженные допущения о расходах при самопродаже (риэлтор 2-5%, аренда 84-135к/3мес, юр.сопровождение 15-50к, реклама 6-45к, дисконт запрос-сделка 10-18% для ЕКБ 2026) по данным Циан/УПН/Домклик; и собственная визуальная система вместо копии референса конкурента (Брусника) — палитра сконвертирована точной OKLab->sRGB матрицей из живых OKLCH-токенов веб-продукта (trade-in.css), типографика DejaVu Serif/Sans/Mono (гарантированно установлены в образе, чинит попутно silent font fallback на Helvetica/Arial, которых там нет). --- .../app/services/exporters/trade_in_pdf.py | 647 +++++++++--------- 1 file changed, 342 insertions(+), 305 deletions(-) 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'' - f'' - f'' - 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"{_html.escape(brand.name)}" + if logo_url + else f"{_html.escape(brand.name).upper()}" + ) + return ( + f'
' + f"{mark_html}" + f'Trade-In отчёт' + f"
" + ) + + +# ── Price range bar — единый CSS-блок (rounded fill), без faux-3D table-хака ── def _price_range_bar( @@ -273,51 +331,45 @@ def _price_range_bar( days_max: int | None = None, show_days: bool = False, ) -> str: - """Полоска диапазона цен в стиле Брусники — PNG через Pillow.""" + """Полоска диапазона цен — один div с закруглённой заливкой в акцентном цвете. + + Раньше рендерилась 4-колоночной table с border-хаком под faux-3D + generic + красной заливкой; теперь — чистый div (та же информация: подписи диапазона, + срок экспозиции при show_days=True).""" 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) - # Ц-таблица: 3 ячейки фиксированной ширины с background-color на td. - # Высота через padding (WeasyPrint stable с padding на td). - days_row = "" + days_html = "" if show_days and days_min is not None and days_max is not None: - days_row = ( - f"" - f'' - f"{days_min} дней" - f'' - f"{days_max} дней" - f"" + days_html = ( + f'
' + f'' + f"{_mono(f'{days_min} дней')}" + f'' + f"{_mono(f'{days_max} дней')}" + f"
" ) return ( - f'' - f'" - f'' - f'' - f'' - f'' - f'' - f"" - f'" - f'" - f"{days_row}" - f"
' - f"{_html.escape(sub_label)}" - f"
' - f"{_html.escape(label_left)}' - f"{_html.escape(label_right)}
" + f'
' + f'
{_html.escape(sub_label)}
' + f'
' + f'
' + f"
" + f'
' + f'' + f"{_mono(_html.escape(label_left))}" + f'' + f"{_mono(_html.escape(label_right))}" + f"
" + f"{days_html}" + f"
" ) @@ -357,35 +409,37 @@ def _price_figure_cell( """Одна ценовая колонка (label + крупное число + диапазон + ₽/м²). Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении — - как web (guard на typeof number). delta_pct — опциональный chip «−N%».""" + как web (guard на typeof number). delta_pct — опциональный chip «−N%». + Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono).""" delta_chip = "" if delta_pct is not None: delta_chip = ( f"−{delta_pct}%" + f"background:{accent};color:#fff;font-size:{_FS_XS};font-weight:700;" + f"border-radius:8pt;vertical-align:middle;'>{_mono(f'−{delta_pct}%')}" ) range_html = "" if range_low is not None and range_high is not None and range_high > range_low: - range_html = ( - f"
" + range_text = ( f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}" - f"
" + ) + range_html = ( + f"
" + f"{_mono(range_text)}
" ) perm2_html = "" if per_m2 is not None and per_m2 > 0: perm2_html = ( - f"
{_fmt_ppm2(per_m2)} ₽/м²
" + f"
" + f"{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}
" ) return ( - f"" - f"
{_html.escape(label)}{delta_chip}
" - f"
{_html.escape(_fmt_rub_m(value_rub))}
" + f"
" + f"{_mono(_html.escape(_fmt_rub_m(value_rub)))}
" f"{range_html}{perm2_html}" f"" ) @@ -423,12 +477,13 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno 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", + accent=_DANGER, delta_pct=_discount_pct(estimate), ) explainer = ( - '

' + f'

' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." @@ -437,7 +492,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno return ( f'{asking_cell}{sold_cell}
' + f'margin:6pt 0 0 0;">{asking_cell}{sold_cell}' f"{explainer}" ) @@ -492,7 +547,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s 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 (как у Брусники). + # Active market subband — 4-118 days range (fallback без данных). # Если есть days_on_market в analogs — берём min/max, иначе фиксированно. days_min, days_max = _days_on_market_range(estimate.analogs) @@ -516,45 +571,35 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s sub_label="Диапазон цен по фактическим сделкам", ) - logo_url = _safe_logo_url(brand.logo_url) - logo_html = ( - f"{_html.escape(brand.name)}" - if logo_url - else f"" - f"{_html.escape(brand.name).upper()}" - ) disclaimer_html = "" if brand.pdf_disclaimer: disclaimer_html = ( - f"

" + f"

" f"{_html.escape(brand.pdf_disclaimer)}

" ) return f"""
- -
- {logo_html} -
+ {_page_header(brand)} -

+

АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ

- - - - - + +
№ отчета{report_num}
Дата отчета{today.strftime("%d.%m.%Y")}
Срок действия данныхдо {expires.strftime("%d.%m.%Y")}
+ + + + - + - - + + @@ -563,26 +608,26 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_dual_price_block(estimate, brand)} -

+

Диапазон цен в объявлениях - (без учета ремонта) + (без учета ремонта)

{listings_bar} -

+

Диапазон цен по фактическим сделкам

{deals_bar} -

+

Что важно при оценке стоимости квартиры:

-
№ отчета{_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}
+
- + @@ -596,8 +641,8 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
Цены в объявлениях — ожидания собственниковФактические сделки проходят на 5–12% ниже, что подтверждают - Росреестр, ДомКлик и продажи агенств недвижимостиФактические сделки проходят на 10–18% ниже, что подтверждают + Росреестр, ДомКлик и продажи агентств недвижимости
Ремонт оценивается по состоянию, а не по вложенным суммам
-

+

Этот отчёт онлайн: {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"""
-
- - {_html.escape(brand.name).upper()} - -
+ {_page_header(brand)} -

+

РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ

@@ -704,17 +745,19 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra - +
Количество объявлений по аналогичным объектам{n_total} шт.
{_mono(f"{n_total} шт.")}
-
Источники данных
+
Источники данных
{sources_html}
- + - - + + +
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{year_range}
Диапазон площади{area_min} - {area_max} м²
Год постройки{_mono(year_range)}
Диапазон площади{_mono(f"{area_min} - {area_max} м²")}
Планировка{rooms_label}
Состояние ремонта{repair_label}
@@ -722,33 +765,34 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra -

Диапазон цен в объявлениях

+

+ Диапазон цен в объявлениях

{range_bar} -

+

Примеры аналогичных объектов с учётом ремонта

- - - - - - {examples_rows}
+ Адрес квартирыИсточникСтоимость 1 м², руб.Стоимость объекта, руб.Срок экспозиции, дней
-