Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2244 lines
114 KiB
Python
2244 lines
114 KiB
Python
"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera.
|
||
|
||
Дизайн-токены (цвет / HUD-хром: header/footer/CSS) портированы из РЕАЛЬНОЙ Mera v2
|
||
HUD/blueprint-идентичности — canonical source:
|
||
tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts (+ Footer.tsx/HeroBar.tsx/
|
||
ObjectSummary.tsx для composition-паттернов). Два предыдущих раунда редизайна
|
||
(PR #2327, #2427) ошибочно опирались на trade-in.css/tradein.html — старую
|
||
"PRINZIP"-брендированную внутреннюю демку, НЕ настоящую Mera-идентичность; этот
|
||
модуль на неё больше не ссылается (см. _PAGE_BG и соседние константы ниже).
|
||
Типографика — PT Serif (заголовки страниц тела отчёта, только жирное начертание),
|
||
PT Sans (текст/таблицы тела отчёта) — оба встроены в PDF как base64 @font-face
|
||
(SIL OFL 1.1, см. app/services/exporters/fonts/), с DejaVu Serif/Sans как
|
||
fallback-цепочкой; DejaVu Sans Mono (числовые значения тела отчёта, tabular-nums)
|
||
остаётся системным — из пакета fonts-dejavu-core, гарантированно установленного в
|
||
Docker-образе. Manrope/IBM Plex Mono (тоже base64 @font-face) — Mera v2 шрифтовая
|
||
пара, используется в HUD-хроме (_page_header/_page_footer); тело отчёта
|
||
(_build_cover/_build_listings_page/_build_deals_page/_build_offer_page) на них пока
|
||
не переключено — следующий этап. Диапазоны цен (_price_range_chart_svg) — реальный
|
||
matplotlib-график (Agg, SVG), не декоративный CSS-div.
|
||
Документ — деловой/утилитарный отчёт (ближе к банковской оценке, чем к
|
||
маркетинговому лендингу), НЕ 1:1 копия конкурентного референса.
|
||
|
||
Структура отчёта — 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 + дистанция + кликабельные ссылки.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import datetime as dt
|
||
import html as _html
|
||
import io
|
||
import logging
|
||
import pathlib
|
||
import re
|
||
from urllib.parse import urlparse
|
||
|
||
import matplotlib
|
||
|
||
matplotlib.use("Agg") # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
|
||
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
|
||
import matplotlib.font_manager as _font_manager
|
||
import segno
|
||
from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price_range_chart_svg
|
||
from matplotlib.patches import Rectangle
|
||
|
||
from app.core.config import settings
|
||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||
|
||
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",
|
||
}
|
||
)
|
||
|
||
# CDN-домены для изображений аналогов (photo_url).
|
||
_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset(
|
||
{
|
||
"images.avito.st",
|
||
"cdn-p.cian.site",
|
||
"avatars.mds.yandex.net",
|
||
}
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── Logo URL и color-sanitation (#13 SSRF / CSS-injection guard) ─────────────
|
||
# Допустимые домены для логотипов брендов (white-label).
|
||
_ALLOWED_LOGO_CDN: frozenset[str] = frozenset(
|
||
{
|
||
"gk-praktika.ru",
|
||
"www.gk-praktika.ru",
|
||
"prinzip.ru",
|
||
"www.prinzip.ru",
|
||
"cdn.gk-praktika.ru",
|
||
}
|
||
)
|
||
|
||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||
|
||
|
||
def _safe_logo_url(url: str | None) -> str | None:
|
||
"""Валидирует logo_url бренда: только https:// + allowlist домен + не data:/file:.
|
||
|
||
data: URI допускаются для инлайн-логотипов (нет сетевого запроса).
|
||
Всё остальное отбрасывается. Если передан file:// — отбрасывается.
|
||
"""
|
||
if not url:
|
||
return None
|
||
try:
|
||
p = urlparse(url)
|
||
except Exception:
|
||
return None
|
||
if p.scheme == "data":
|
||
# data: URI безопасны — нет SSRF, только инлайн
|
||
return url
|
||
if p.scheme != "https":
|
||
# file://, http://, javascript: — все отбрасываем
|
||
return None
|
||
if p.netloc not in _ALLOWED_LOGO_CDN:
|
||
logger.warning("logo_url domain not in allowlist: %s", p.netloc)
|
||
return None
|
||
return url
|
||
|
||
|
||
def _safe_color(color: str | None, fallback: str) -> str:
|
||
"""Принимает только #RRGGBB hex. Всё остальное → fallback.
|
||
|
||
Защита от CSS-injection через primary_color/accent_color бренда
|
||
(WeasyPrint вставляет значение напрямую в style=).
|
||
"""
|
||
if color and _COLOR_RE.match(color):
|
||
return color
|
||
return fallback
|
||
|
||
|
||
# ── WeasyPrint custom url_fetcher (SSRF guard) ───────────────────────────────
|
||
|
||
|
||
def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
|
||
"""Возвращает url_fetcher для WeasyPrint, блокирующий file:// и нелоговые схемы.
|
||
|
||
Разрешены: https:// (логотипы + шрифты CDN) и data: (инлайн SVG/PNG).
|
||
Блокированы: file://, http://, ftp:// и любые другие.
|
||
"""
|
||
|
||
def fetcher(url: str, timeout: int = 10, ssl_context=None): # type: ignore[no-untyped-def]
|
||
# Импорт отложен до момента первого реального fetch, чтобы тесты могли
|
||
# подменять weasyprint.urls через sys.modules без ModuleNotFoundError.
|
||
from weasyprint.urls import default_url_fetcher
|
||
|
||
try:
|
||
p = urlparse(url)
|
||
except Exception:
|
||
raise ValueError(f"Invalid URL in PDF resource: {url!r}") from None
|
||
if p.scheme in ("data", "https"):
|
||
return default_url_fetcher(url, timeout=timeout, ssl_context=ssl_context)
|
||
raise ValueError(
|
||
f"PDF resource scheme '{p.scheme}' is not allowed (only data: and https:). URL: {url!r}"
|
||
)
|
||
|
||
return fetcher
|
||
|
||
|
||
# ── Design tokens (Mera v2 HUD design system) ───────────────────────────────
|
||
# Портировано 1:1 из канонического токен-контракта /trade-in/v2:
|
||
# tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts
|
||
# Предыдущие раунды (PR #2327/#2427) ошибочно брали этот блок из trade-in.css /
|
||
# tradein.html — несвязанной "PRINZIP"-брендированной внутренней демки, НЕ
|
||
# настоящей Mera-идентичности продукта. Это исправленный источник.
|
||
#
|
||
# Константы, помеченные "legacy alias", НЕ часть контракта tokens.ts — они
|
||
# существуют только потому, что тело отчёта (_build_cover/_build_listings_page/
|
||
# _build_deals_page/_build_offer_page — не тронуто в этом этапе, следующий этап)
|
||
# по-прежнему ссылается на старые имена; их ЗНАЧЕНИЯ указывают на ближайший
|
||
# Mera-эквивалент, чтобы весь документ (старое тело + новый HUD-хром) читался
|
||
# как одна согласованная палитра, а не два конфликтующих во время переходного
|
||
# многоэтапного редизайна.
|
||
_PAGE_BG = "#dde6ef" # tokens.pageBg — фон страницы
|
||
_INK = "#1c2c40" # tokens.ink — заголовки / основной текст
|
||
_INK_2 = "#17263a" # tokens.ink2 — вторичный ink
|
||
_BODY = "#39506a" # tokens.body — текст тела
|
||
_BODY_2 = "#46586c" # tokens.body2 — текст тела, более выразительный (wordmark футера)
|
||
_MUTED = "#586777" # tokens.muted — самый выразительный приглушённый (лейблы/подписи)
|
||
_MUTED_2 = "#5c6672" # tokens.muted2 — заголовки карточек / meta-лейблы (header/footer)
|
||
_LINE = "#c2d3e3" # tokens.line — базовый hairline/бордер
|
||
_LINE_2 = "#c8d7e6" # tokens.line2 — бордер карточки
|
||
_LINE_SOFT = "#d3dfeb" # tokens.lineSoft — вертикальные hairline (meta-cell разделители)
|
||
_LINE_DOTTED = "#cbd7e3" # tokens.lineGradient — пунктирные row-разделители / центр футера
|
||
_ACCENT = "#2e8bff" # tokens.accent
|
||
_ACCENT_DEEP = "#0d6fd6" # tokens.accentDeep
|
||
_ACCENT_LITE = "#46a8ff" # tokens.accentLite
|
||
_SUCCESS = "#1baa6b" # tokens.success
|
||
_SUCCESS_BORDER = "#b6e0cc" # tokens.successBorder
|
||
_WARN = "#b58a3e" # tokens.warn
|
||
_DANGER = "#cd6868" # tokens.danger
|
||
_BADGE_TINT = "#eaf1f8" # tokens.badgeTint — непрозрачная бледно-синяя заливка чипа
|
||
_CARD_BG = "#eef3f7" # flattened tokens.surface.w50 поверх _PAGE_BG — у WeasyPrint нет
|
||
# backdrop-filter/blur, поэтому «стеклянная» карточка — заранее посчитанная плоская
|
||
# заливка вместо rgba(255,255,255,.5) + blur (см. фон карточки в ObjectSummary.tsx).
|
||
_BRACKET = "#9bbce0" # tokens.bracket — акцент HUD corner-bracket
|
||
_TRACK = "#dbe6f1" # tokens.track — «range track + donut track» (дословно из tokens.ts,
|
||
# один токен на оба применения: подложка _price_range_chart_svg И трек-круг
|
||
# _data_quality_gauge_svg — этот этап добавляет второе использование).
|
||
|
||
# Legacy aliases — см. комментарий блока выше; убрать при редизайне тела отчёта.
|
||
_FG = _INK
|
||
_ACCENT_SOFT = _BADGE_TINT
|
||
_ACCENT_2 = _WARN # у Mera нет отдельного тёплого "accent-2" — ближайший аналог
|
||
_ACCENT_2_SOFT = "#f6f1e8" # мягкий тёплый тон (12% _WARN на белом) — иконки Offer-страницы
|
||
_DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) — фон warning-плашек
|
||
_BORDER = _LINE
|
||
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
|
||
|
||
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
|
||
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
|
||
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
|
||
# HUD, а не версия PDF-модуля/API.
|
||
_REPORT_ENGINE_VERSION = "v2.0.6"
|
||
|
||
# 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, PT Serif)
|
||
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
|
||
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
|
||
|
||
|
||
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
|
||
# Раньше CSS декларировал 'DejaVu Serif'/'DejaVu Sans' — это системные fallback-шрифты
|
||
# без гарантированного визуального качества (только моноширинный 'DejaVu Sans Mono'
|
||
# для цифр остаётся — не часть этой замены, см. _mono()). Теперь настоящие PT Sans/
|
||
# PT Serif встроены как base64 @font-face (см. _build_css) — файлы читаются и
|
||
# кодируются ОДИН раз при импорте модуля, не на каждый рендер PDF.
|
||
_FONTS_DIR = pathlib.Path(__file__).parent / "fonts"
|
||
|
||
|
||
def _font_data_url(filename: str) -> str:
|
||
"""Читает .ttf из fonts/ (рядом с этим модулем) и возвращает data:font/ttf;base64 URI."""
|
||
data = (_FONTS_DIR / filename).read_bytes()
|
||
return f"data:font/ttf;base64,{base64.b64encode(data).decode('ascii')}"
|
||
|
||
|
||
_PT_SANS_REGULAR_URL = _font_data_url("PTSans-Regular.ttf")
|
||
_PT_SANS_BOLD_URL = _font_data_url("PTSans-Bold.ttf")
|
||
_PT_SERIF_BOLD_URL = _font_data_url("PTSerif-Bold.ttf")
|
||
|
||
# Manrope (UI-текст) и IBM Plex Mono (числа/лейблы) — та же пара шрифтов, что
|
||
# зарегистрирована в живом /trade-in/v2 (см. frontend/src/app/v2/layout.tsx,
|
||
# next/font/google, subsets latin+cyrillic). Variable-font Manrope[wght].ttf
|
||
# инстанцирован в статические Regular/Bold через fonttools varLib.instancer
|
||
# (варьируемые шрифты не поддерживаются как @font-face src в WeasyPrint) —
|
||
# исходный variable-файл НЕ коммитится, только полученные статические веса.
|
||
# IBM Plex Mono подключён только 400/500 (см. @font-face ниже) — bold mono
|
||
# не понадобился нигде в текущей вёрстке.
|
||
_MANROPE_REGULAR_URL = _font_data_url("Manrope-Regular.ttf")
|
||
_MANROPE_BOLD_URL = _font_data_url("Manrope-Bold.ttf")
|
||
_IBM_PLEX_MONO_REGULAR_URL = _font_data_url("IBMPlexMono-Regular.ttf")
|
||
_IBM_PLEX_MONO_MEDIUM_URL = _font_data_url("IBMPlexMono-Medium.ttf")
|
||
|
||
# Регистрируем файлы в matplotlib. С этого этапа chart endpoint-labels (см.
|
||
# _price_range_chart_svg) рендерятся Manrope — той же гарнитурой, что и весь
|
||
# HUD-хром (_page_header/_page_footer) и (следующий шаг) тело отчёта; PT Sans
|
||
# по-прежнему зарегистрирован (нужен для @font-face тела документа, ещё не
|
||
# переключённого на Manrope в этом этапе).
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Regular.ttf"))
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Bold.ttf"))
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Regular.ttf"))
|
||
_font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Bold.ttf"))
|
||
matplotlib.rcParams["font.family"] = "Manrope"
|
||
|
||
|
||
# ── 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"), # Циан фирменный синий
|
||
"domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер
|
||
"yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный
|
||
"rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий
|
||
"etazhi": ("#e30613", "#fff"), # Этажи красный
|
||
}
|
||
|
||
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
|
||
"avito": "Avito",
|
||
"cian": "Циан",
|
||
"domklik": "Домклик · Сбер",
|
||
"yandex": "Я.Недвижимость",
|
||
"rosreestr": "Росреестр",
|
||
"etazhi": "Этажи",
|
||
}
|
||
|
||
|
||
def _source_logo_pill(source: str) -> str:
|
||
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
|
||
акцентная точка цвета источника + название на светлой заливке. Раньше — сплошной
|
||
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
|
||
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из
|
||
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) —
|
||
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
|
||
произвольной длины (см. fallback ниже)."""
|
||
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
|
||
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
|
||
return (
|
||
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
|
||
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
|
||
"overflow-wrap:anywhere;'>"
|
||
f"<span style='width:5pt;height:5pt;border-radius:50%;background:{dot};"
|
||
f"display:inline-block;flex-shrink:0;'></span>{_html.escape(name)}</span>"
|
||
)
|
||
|
||
|
||
def _source_badge_inline(source: str | None) -> str:
|
||
"""Маленький source badge для table cells (без фона)."""
|
||
if not source:
|
||
return f"<span style='color:{_MUTED};'>—</span>"
|
||
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#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:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
|
||
)
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
_NBSP = "\u00a0" # неразрывный пробел — разряды/валюта не переносятся посередине
|
||
|
||
|
||
def _fmt_rub(value: int) -> str:
|
||
"""12 500 000 ₽ — неразрывные пробелы, чтобы число никогда не переносилось
|
||
посередине (разряды/символ валюты не должны сиротеть на разных строках)."""
|
||
return f"{value:,}".replace(",", _NBSP) + _NBSP + "₽"
|
||
|
||
|
||
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 _mono(text: str) -> str:
|
||
"""Оборачивает числовой текст (цены/даты/%/№ отчёта/м²/₽-м²/срок экспозиции)
|
||
в моноширинный span — DejaVu Sans Mono + tabular-nums (см. .mono в _build_css),
|
||
зеркалит web-конвенцию .num/.mono из trade-in.css (HeroSummary.tsx и др.)."""
|
||
return f"<span class='mono'>{text}</span>"
|
||
|
||
|
||
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
|
||
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
|
||
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
|
||
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
|
||
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
|
||
_CITY_REPORT_PREFIX: dict[str, str] = {
|
||
"екатеринбург": "ЕКБ",
|
||
"нижний тагил": "НТ",
|
||
"каменск-уральский": "КУ",
|
||
"первоуральск": "ПРВ",
|
||
"серов": "СЕР",
|
||
"новоуральск": "НВУ",
|
||
"ревда": "РЕВ",
|
||
"полевской": "ПЛВ",
|
||
"асбест": "АСБ",
|
||
"верхняя пышма": "ВП",
|
||
"березовский": "БРЗ",
|
||
"краснотурьинск": "КРТ",
|
||
"камышлов": "КМШ",
|
||
}
|
||
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
|
||
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
|
||
_DEFAULT_REPORT_PREFIX = "МЕРА"
|
||
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
|
||
_REPORT_CITY_RE = re.compile(
|
||
r"\b(?:"
|
||
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
|
||
+ r")\b"
|
||
)
|
||
|
||
|
||
def _report_city_prefix(address: str | None) -> str:
|
||
"""Буквенный префикс № отчёта по городу из адреса (region 66).
|
||
|
||
«МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод
|
||
«EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до
|
||
поиска (симметрично _resolve_target_city в estimator.py)."""
|
||
if not address:
|
||
return _DEFAULT_REPORT_PREFIX
|
||
norm = address.replace("ё", "е").replace("Ё", "е").lower()
|
||
m = _REPORT_CITY_RE.search(norm)
|
||
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
|
||
|
||
|
||
def _report_number(estimate: AggregatedEstimate) -> str:
|
||
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
|
||
|
||
PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА»
|
||
если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от
|
||
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
|
||
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
|
||
short = int(estimate.estimate_id.int) % 10_000
|
||
long = int(estimate.estimate_id.int) % 10_000_000_000
|
||
return f"{prefix}-{short:04d}-{long:010d}"
|
||
|
||
|
||
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
|
||
"""Срок действия данных отчёта — estimate.expires_at, fallback +30 дней.
|
||
|
||
Та же логика, что в _build_cover; вынесена сюда, чтобы _page_footer могла
|
||
показать «ДЕЙСТВИТЕЛЕН ДО» одинаково на всех страницах отчёта."""
|
||
if estimate.expires_at is not None:
|
||
return estimate.expires_at.date()
|
||
return dt.date.today() + dt.timedelta(days=30)
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ── Unified page header/footer — HUD idiom, портировано из /trade-in/v2
|
||
# (HeroBar.tsx meta-cells + Footer.tsx wordmark composition). Раньше — плашка с
|
||
# логотипом + текстовая полоска (cover) / голая footer-строка (pages 2-4); теперь
|
||
# единый персистентный masthead + единый footer на ВСЕХ 4 страницах отчёта. ────
|
||
|
||
|
||
def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: ignore[no-untyped-def]
|
||
"""Masthead: слева — wordmark бренда (точка + tracked-uppercase имя, трактовка
|
||
Footer.tsx, но цвет по-прежнему driven brand.primary_color — white-label не
|
||
тронут), справа — компактные mono meta-cells (№ отчёта / дата), разделённые
|
||
тонкими вертикальными hairline (см. meta-row HeroBar.tsx)."""
|
||
logo_url = _safe_logo_url(brand.logo_url)
|
||
if logo_url:
|
||
# <img> уже жёстко ограничен max-height/max-width — переполнение исключено
|
||
# само по себе; brand.name виден только в alt (не рендерится как текст).
|
||
mark_html = (
|
||
f"<span style='flex-shrink:0;'>"
|
||
f"<img src='{_html.escape(logo_url)}' alt='{_html.escape(brand.name)}' "
|
||
f"style='max-height:20pt;max-width:110pt;object-fit:contain;"
|
||
f"vertical-align:middle;'/></span>"
|
||
)
|
||
else:
|
||
# #overflow-hardening: brand.name — white-label произвольная строка, может
|
||
# быть длиннее «Trade-In»/«Практика» — min-width:0 + overflow-wrap:anywhere
|
||
# позволяет ей перенестись, а не наехать на meta-cell справа (flex-row).
|
||
mark_html = (
|
||
f"<span style='display:inline-flex;align-items:center;gap:5pt;min-width:0;"
|
||
f"flex:1 1 auto;overflow-wrap:anywhere;'>"
|
||
f"<span style='display:inline-block;width:5pt;height:5pt;border-radius:50%;"
|
||
f"flex-shrink:0;background:{brand.primary_color};'></span>"
|
||
f"<span style='font-size:{_FS_LG};font-weight:700;letter-spacing:0.14em;"
|
||
f"color:{brand.primary_color};'>{_html.escape(brand.name).upper()}</span>"
|
||
f"</span>"
|
||
)
|
||
|
||
def _meta_cell(label: str, value: str) -> str:
|
||
# label/value — короткие гарантированно-ограниченные значения (лейбл ≤6
|
||
# символов, № отчёта/дата — фиксированный формат) — nowrap оправдан здесь.
|
||
return (
|
||
f"<span style='display:inline-block;padding-left:11pt;margin-left:11pt;"
|
||
f"border-left:1pt solid {_LINE_SOFT};white-space:nowrap;'>"
|
||
f"<span style='display:block;font-size:{_FS_XS};letter-spacing:0.12em;"
|
||
f"color:{_MUTED_2};'>{_html.escape(label)}</span>"
|
||
f'<span style=\'font-family:"IBM Plex Mono","DejaVu Sans Mono",monospace;'
|
||
f"font-variant-numeric:tabular-nums;font-size:{_FS_SM};color:{_INK};'>"
|
||
f"{_html.escape(value)}</span></span>"
|
||
)
|
||
|
||
meta_html = _meta_cell("ОТЧЁТ", report_num) + _meta_cell(
|
||
"ДАТА", report_date.strftime("%d.%m.%Y")
|
||
)
|
||
|
||
return (
|
||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
||
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
|
||
f'padding-bottom:6pt;margin-bottom:9pt;">'
|
||
f"{mark_html}"
|
||
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
def _page_footer(
|
||
brand, # type: ignore[no-untyped-def]
|
||
report_num: str,
|
||
report_date: dt.date,
|
||
page_note: str | None = None,
|
||
*,
|
||
valid_until: dt.date | None = None,
|
||
) -> str:
|
||
"""Единый footer — порт 3-частной композиции Footer.tsx (/trade-in/v2), но
|
||
развёрнутый в 2 явных строки, а не 3 flex-item'а с fallback-переносом в одну
|
||
(см. ниже #overflow-hardening про причину):
|
||
|
||
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
||
линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
|
||
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
|
||
|
||
page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
|
||
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
||
удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять
|
||
полезную для печатного многостраничного отчёта информацию.
|
||
"""
|
||
note_html = ""
|
||
if page_note:
|
||
note_html = (
|
||
f'<div style="font-size:{_FS_XS};color:{_MUTED_2};padding-bottom:5pt;'
|
||
f'overflow-wrap:anywhere;">{page_note}</div>'
|
||
)
|
||
valid_until_html = ""
|
||
if valid_until is not None:
|
||
valid_until_html = (
|
||
f'<span style="margin-left:16pt;">ДЕЙСТВИТЕЛЕН ДО '
|
||
f'<span style="color:{_MUTED};">{valid_until.strftime("%d.%m.%Y")}</span></span>'
|
||
)
|
||
mono_family = "'IBM Plex Mono','DejaVu Sans Mono',monospace"
|
||
# #overflow-hardening: изначально meta / декоративная линия-коннектор / wordmark
|
||
# были 3 flex-child'а ОДНОЙ строки с `flex-wrap:wrap` как fallback на случай
|
||
# нехватки места. На практике meta (особенно со СРОК ДЕЙСТВИЯ на Cover) почти
|
||
# всегда заполняет строку целиком, так что wordmark systematically уезжал на
|
||
# свою отдельную строку — но даже НА ОТДЕЛЬНОЙ, целиком его строке, WeasyPrint
|
||
# заметно занижал доступную ширину wordmark-item (воспроизводится детерминированно
|
||
# и не зависело от max-width/margin декоративной линии или flex-shrink на самом
|
||
# wordmark — похоже на ограничение реализации multi-line flex-wrap в WeasyPrint,
|
||
# а не на конкуренцию за ширину), из-за чего даже короткий generic бренд
|
||
# «Trade-In» рвался ПОСРЕДИ слова («TRADE-» / «IN») на КАЖДОЙ странице с footer'ом.
|
||
# Фикс — не патчить flex-veights, а убрать саму двусмысленность: 2 явные
|
||
# block-строки (meta / wordmark) вместо одной строки с fallback-переносом.
|
||
# Декоративный коннектор (2 точки+градиент с каждой стороны) как самостоятельный
|
||
# flex-item убран — он дублировал функцию border-top (уже отделяет footer от
|
||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||
# градиентную линию-разделитель между строками meta/wordmark.
|
||
return f"""
|
||
<div style="margin-top:20pt;">
|
||
{note_html}
|
||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
||
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
||
color:{_MUTED_2};">
|
||
<div style="white-space:nowrap;overflow:hidden;">
|
||
ОТЧЁТ <span style="color:{_MUTED};">{_html.escape(report_num)}</span>
|
||
<span style="margin-left:16pt;">ДАТА
|
||
<span style="color:{_MUTED};">{report_date.strftime("%d.%m.%Y")}</span></span>
|
||
{valid_until_html}
|
||
</div>
|
||
<div style="height:1pt;margin:6pt 0;background:linear-gradient(90deg,
|
||
transparent,{_LINE_DOTTED} 15%,{_ACCENT} 50%,{_LINE_DOTTED} 85%,
|
||
transparent);"></div>
|
||
<div style="display:flex;align-items:center;gap:7pt;min-width:0;">
|
||
<span style="width:4pt;height:4pt;border-radius:50%;flex-shrink:0;
|
||
background:{_ACCENT};display:inline-block;"></span>
|
||
<span style="flex:1 1 auto;font-family:'Manrope','DejaVu Sans',sans-serif;
|
||
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
||
min-width:0;overflow-wrap:anywhere;">
|
||
{_html.escape(brand.name).upper()}</span>
|
||
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
|
||
flex-shrink:0;white-space:nowrap;">
|
||
{_REPORT_ENGINE_VERSION}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── Price range chart — matplotlib SVG bullet/range chart (real chart, не div) ──
|
||
# Раньше рендерилась одним div с фиксированной 64%-заливкой/14%-отступом — числа
|
||
# НЕ определяли позицию заливки (чисто декоративный "прогресс-бар", не привязанный
|
||
# к range_low/range_high). Теперь — настоящий график: серый трек на всю область
|
||
# построения + акцентная заливка ровно на [range_low, range_high] (в млн ₽) + подписи-
|
||
# концы (tabular figures, тот же PT Sans, что и весь документ) + опционально срок
|
||
# экспозиции. Рендерится headless (Agg backend, см. import matplotlib.use выше) в
|
||
# SVG и встраивается как base64 <img> — WeasyPrint рендерит SVG только через image
|
||
# pipeline (не как inline-разметку), см. reference prototype build_report.py.
|
||
|
||
|
||
def _price_range_chart_svg(
|
||
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,
|
||
accent: str = _ACCENT,
|
||
) -> str:
|
||
"""Bullet/range-chart диапазона цен (matplotlib Agg → SVG → base64 <img>).
|
||
|
||
Подложка-трек (светло-серый) + закрашенная акцентная зона на реальном диапазоне
|
||
[range_low, range_high] + акцентные end-tick'и + подписи-концы + опционально
|
||
срок экспозиции (show_days=True) — та же информация, что раньше показывал
|
||
_price_range_bar (удалена), но настоящий график, а не декоративный div."""
|
||
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)
|
||
|
||
lo_m = range_low / 1_000_000
|
||
hi_m = range_high / 1_000_000
|
||
pad = (hi_m - lo_m) * 0.08 or 0.3
|
||
|
||
y = 0.5
|
||
track_h = 0.34
|
||
|
||
# Высота figure (points) задаёт только масштаб track/pad — реальная высота
|
||
# итогового SVG определяется bbox_inches="tight" (см. savefig ниже) по фактическому
|
||
# содержимому, включая подписи ниже track. Подписи — через ax.annotate с
|
||
# textcoords="offset points": абсолютный точечный отступ, НЕ зависящий от масштаба
|
||
# data-координат/figsize (в отличие от прежней data-fraction схемы, которая на
|
||
# маленьком figsize схлопывала строки label/days друг в друга).
|
||
# Figure() object API (НЕ pyplot.subplots) — write_pdf() выполняется в FastAPI
|
||
# threadpool-executor (sync endpoint), где несколько запросов на PDF могут рендерить
|
||
# график конкурентно; pyplot держит глобальный figure-manager, не потокобезопасный
|
||
# для такого сценария (см. matplotlib FAQ "Embedding in a web application server").
|
||
# Figure() не регистрируется нигде глобально — никакой утечки, plt.close() не нужен.
|
||
fig = Figure(figsize=(6.85, 28 / 72), dpi=200)
|
||
ax = fig.subplots()
|
||
fig.patch.set_alpha(0)
|
||
ax.set_facecolor("none")
|
||
# Axes растянуты на всю figure — предсказуемая точка отсчёта для track_bottom.
|
||
ax.set_position((0, 0, 1, 1))
|
||
|
||
track_bottom = y - track_h / 2
|
||
|
||
# подложка-трек — на всю область построения (визуальный "инструмент")
|
||
ax.add_patch(
|
||
Rectangle(
|
||
(lo_m - pad, track_bottom),
|
||
(hi_m - lo_m) + 2 * pad,
|
||
track_h,
|
||
facecolor=_TRACK,
|
||
edgecolor="none",
|
||
zorder=1,
|
||
)
|
||
)
|
||
# закрашенная акцентная зона — реальный диапазон range_low..range_high
|
||
ax.add_patch(
|
||
Rectangle(
|
||
(lo_m, track_bottom),
|
||
hi_m - lo_m,
|
||
track_h,
|
||
facecolor=accent,
|
||
edgecolor="none",
|
||
zorder=2,
|
||
)
|
||
)
|
||
# end-tick'и по границам активной зоны (bullet-chart акцент)
|
||
for x in (lo_m, hi_m):
|
||
ax.plot(
|
||
[x, x],
|
||
[track_bottom - 0.04, y + track_h / 2 + 0.04],
|
||
color=accent,
|
||
linewidth=2.2,
|
||
solid_capstyle="round",
|
||
zorder=3,
|
||
)
|
||
|
||
# Размер подписей выровнен с типографской шкалой документа (_FS_MD/_FS_XS) —
|
||
# тот же масштаб, что раньше показывал div-бар.
|
||
for x, label, ha in ((lo_m, label_left, "left"), (hi_m, label_right, "right")):
|
||
ax.annotate(
|
||
label,
|
||
xy=(x, track_bottom),
|
||
xycoords="data",
|
||
xytext=(0, -4),
|
||
textcoords="offset points",
|
||
color=_INK,
|
||
fontsize=10.5,
|
||
ha=ha,
|
||
va="top",
|
||
fontweight="bold",
|
||
annotation_clip=False,
|
||
)
|
||
|
||
if show_days and days_min is not None and days_max is not None:
|
||
for x, days, ha in ((lo_m, days_min, "left"), (hi_m, days_max, "right")):
|
||
ax.annotate(
|
||
f"{days} дней",
|
||
xy=(x, track_bottom),
|
||
xycoords="data",
|
||
xytext=(0, -19),
|
||
textcoords="offset points",
|
||
color=_DANGER,
|
||
fontsize=8,
|
||
ha=ha,
|
||
va="top",
|
||
fontweight="bold",
|
||
annotation_clip=False,
|
||
)
|
||
|
||
ax.set_xlim(lo_m - pad, hi_m + pad)
|
||
ax.set_ylim(0, 1)
|
||
ax.axis("off")
|
||
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format="svg", transparent=True, bbox_inches="tight", pad_inches=0.03)
|
||
chart_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||
|
||
return (
|
||
f'<div style="margin:3pt 0;">'
|
||
f'<div style="text-align:center;font-size:{_FS_SM};color:{_MUTED};'
|
||
f'margin-bottom:3pt;">{_html.escape(sub_label)}</div>'
|
||
f'<img style="width:100%;display:block;" alt="{_html.escape(sub_label)}" '
|
||
f'src="data:image/svg+xml;base64,{chart_b64}">'
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
# ── 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%».
|
||
Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono).
|
||
|
||
HUD-карточка (card-glass + bracket-tr, corner-bracket accent из tokens.ts) —
|
||
вложенный <div> внутри <td> (НЕ position:relative на самом <td> — надёжность
|
||
absolute-позиционированного ::before внутри table cell в разных движках
|
||
рендеринга непредсказуема). padding-right на label-строке — clearance от
|
||
delta_chip (короткий bounded chip, но всё равно на одной строке с label)."""
|
||
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:{_FS_XS};font-weight:700;"
|
||
f"border-radius:8pt;vertical-align:middle;white-space:nowrap;'>"
|
||
f"{_mono(f'−{delta_pct}%')}</span>"
|
||
)
|
||
range_html = ""
|
||
if range_low is not None and range_high is not None and range_high > range_low:
|
||
range_text = (
|
||
f"{_html.escape(_fmt_rub_m(range_low))} – {_html.escape(_fmt_rub_m(range_high))}"
|
||
)
|
||
range_html = (
|
||
f"<div style='font-size:{_FS_MD};color:{_MUTED};font-weight:600;margin-top:3pt;"
|
||
f"overflow-wrap:anywhere;'>{_mono(range_text)}</div>"
|
||
)
|
||
perm2_html = ""
|
||
if per_m2 is not None and per_m2 > 0:
|
||
perm2_html = (
|
||
f"<div style='font-size:{_FS_SM};color:{_MUTED};margin-top:2pt;"
|
||
f"overflow-wrap:anywhere;'>{_mono(f'{_fmt_ppm2(per_m2)} ₽/м²')}</div>"
|
||
)
|
||
return (
|
||
f"<td style='width:50%;vertical-align:top;padding:0;'>"
|
||
f"<div class='card-glass bracket-tr' style='padding:8pt 12pt 7pt 12pt;"
|
||
f"border-top:2pt solid {accent};border-radius:4pt;overflow-wrap:anywhere;'>"
|
||
f"<div style='font-size:{_FS_SM};color:{_MUTED};font-weight:700;text-transform:uppercase;"
|
||
f"letter-spacing:0.03em;padding-right:16pt;'>{_html.escape(label)}{delta_chip}</div>"
|
||
f"<div style='font-size:{_FS_XXL};font-weight:900;color:{_INK};margin-top:4pt;"
|
||
f"overflow-wrap:anywhere;'>{_mono(_html.escape(_fmt_rub_m(value_rub)))}</div>"
|
||
f"{range_html}{perm2_html}"
|
||
f"</div></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=_DANGER,
|
||
delta_pct=_discount_pct(estimate),
|
||
)
|
||
|
||
explainer = (
|
||
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
|
||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
|
||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||
"Росреестра, обычно ниже запроса."
|
||
"</p>"
|
||
)
|
||
|
||
return (
|
||
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
||
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||
f"{explainer}"
|
||
)
|
||
|
||
|
||
# ── Shared HUD chrome for page bodies (Mera v2 — section-badge/status-pill/
|
||
# card-glass+bracket) — used by all 4 page-builders below. ──────────────────
|
||
|
||
|
||
def _section_heading(number: str, text: str, *, tag: str = "h2", extra_right: str = "") -> str:
|
||
"""Заголовок страницы (h1 Cover / h2 Listings-Deals-Offer) с моно-нумерацией
|
||
секции — HUD-идиома ObjectSummary.tsx («03 СВОДКА ОБЪЕКТА»), теперь общая для
|
||
всех 4 страниц отчёта (раньше — только у cover не было border-bottom, у
|
||
остальных был раздельный inline-стиль на каждой странице).
|
||
|
||
extra_right — опциональный элемент справа (напр. status-pill); свой flex-item
|
||
с flex-shrink:0, заголовок — flex:1 1 auto с min-width:0, чтобы длинный текст
|
||
(в этом модуле текст заголовков статический, но структура остаётся safe by
|
||
construction для любого будущего динамического текста) переносился, а не
|
||
наезжал на правый элемент."""
|
||
size = _FS_XL if tag == "h1" else _FS_LG
|
||
right_html = f'<span style="flex-shrink:0;">{extra_right}</span>' if extra_right else ""
|
||
return (
|
||
'<div style="display:flex;align-items:baseline;justify-content:space-between;'
|
||
f"gap:10pt;flex-wrap:wrap;border-bottom:1.5pt solid {_LINE};padding-bottom:5pt;"
|
||
'margin-bottom:12pt;">'
|
||
'<div style="display:flex;align-items:baseline;gap:8pt;min-width:0;flex:1 1 auto;">'
|
||
f'<span class="section-badge">{number}</span>'
|
||
f"<{tag} style=\"font-family:'PT Serif','DejaVu Serif',serif;font-size:{size};"
|
||
"font-weight:700;text-transform:uppercase;letter-spacing:0.02em;line-height:1.25;"
|
||
f'margin:0;">{text}</{tag}>'
|
||
"</div>"
|
||
f"{right_html}"
|
||
"</div>"
|
||
)
|
||
|
||
|
||
def _freshness_status_pill(freshness_minutes: int | None) -> str:
|
||
"""Status-pill (.status-pill — ObjectSummary.tsx «АКТУАЛЬНО» идиома) — честная
|
||
метка свежести данных из estimate.data_freshness_minutes (реальное поле, НЕ
|
||
fabricated метрика). Пусто, если поле не заполнено (graceful — старые записи
|
||
без этого поля/пустой рехайдрат) — по аналогии с остальными optional-полями
|
||
оценки в этом модуле (см. _dual_price_block)."""
|
||
if freshness_minutes is None:
|
||
return ""
|
||
hours = freshness_minutes / 60
|
||
if hours <= 24:
|
||
label, color, border = "АКТУАЛЬНО", _SUCCESS, _SUCCESS_BORDER
|
||
elif hours <= 24 * 7:
|
||
label, color, border = "ОБНОВЛЕНО НА ЭТОЙ НЕДЕЛЕ", _WARN, "#e3d4b3"
|
||
else:
|
||
label, color, border = "ТРЕБУЕТ ОБНОВЛЕНИЯ", _DANGER, "#e8c9c9"
|
||
# Три фиксированных RU-строки из закрытого набора (не free-form данные) —
|
||
# nowrap оправдан тем же порядком, что и у web-референса (ObjectSummary.tsx
|
||
# ставит whiteSpace:"nowrap" на идентичный pill).
|
||
return (
|
||
f"<span class='status-pill' style='color:{color};border-color:{border};"
|
||
f"white-space:nowrap;'>{label}</span>"
|
||
)
|
||
|
||
|
||
# Слоты источников для «Качество данных» — портировано 1:1 из mappers.ts
|
||
# SOURCE_SLOTS/TOTAL_SOURCES (см. buildSources/activeSourceCount): 5 живых
|
||
# источников; N1/Restate намеренно исключены там же (#2081/#2204 — мёртвые).
|
||
_QUALITY_SOURCE_SLOTS: tuple[tuple[str, ...], ...] = (
|
||
("cian",),
|
||
("yandex",),
|
||
("rosreestr",),
|
||
("avito", "avito_imv"),
|
||
("domklik", "domclick"),
|
||
)
|
||
_TOTAL_SOURCES = len(_QUALITY_SOURCE_SLOTS)
|
||
|
||
# Web-референс (ObjectSummary.tsx, статичная demo-fixture): confidence="средняя"
|
||
# → strokeDasharray=126/strokeDashoffset=72 → дуга заполнена ~43%. low/high —
|
||
# пропорциональная экстраполяция. Это категориальное визуальное кодирование ТОГО
|
||
# ЖЕ поля confidence, что уже показано текстом рядом (см. _conf_label) — НЕ
|
||
# отдельная выдуманная точная метрика (в AggregatedEstimate нет отдельного
|
||
# числового quality-score).
|
||
_QUALITY_TIER_PCT: dict[str, float] = {"high": 0.78, "medium": 0.43, "low": 0.22}
|
||
|
||
|
||
def _coeff_var_pct(values: list[float]) -> int | None:
|
||
"""Коэффициент вариации (%, округлён до целого) — портировано 1:1 из
|
||
mappers.ts coeffVar/cvStr (population stddev / mean × 100). None при <2
|
||
валидных (>0) значениях — как на web рендерится «—»."""
|
||
clean = [v for v in values if v and v > 0]
|
||
if len(clean) < 2:
|
||
return None
|
||
mean = sum(clean) / len(clean)
|
||
if mean == 0:
|
||
return None
|
||
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
|
||
return round((variance**0.5) / mean * 100)
|
||
|
||
|
||
def _active_source_count(estimate: AggregatedEstimate) -> int:
|
||
"""Портировано 1:1 из mappers.ts activeSourceCount/buildSources — слот
|
||
активен, если он в sources_used ИЛИ реально дал лоты (analogs+actual_deals)."""
|
||
counts: dict[str, int] = {}
|
||
for lot in (*estimate.analogs, *estimate.actual_deals):
|
||
s = (lot.source or "").lower()
|
||
if s:
|
||
counts[s] = counts.get(s, 0) + 1
|
||
used = {s.lower() for s in (estimate.sources_used or [])}
|
||
active = 0
|
||
for slot in _QUALITY_SOURCE_SLOTS:
|
||
count = sum(counts.get(k, 0) for k in slot)
|
||
if count > 0 or any(k in used for k in slot):
|
||
active += 1
|
||
return active
|
||
|
||
|
||
def _data_quality_gauge_svg(confidence: str, *, size: int = 46) -> str:
|
||
"""Кольцевой SVG-индикатор — техника ObjectSummary.tsx (2 концентрических
|
||
<circle>, stroke-dasharray/stroke-dashoffset), инлайн в HTML (WeasyPrint
|
||
поддерживает inline <svg> — см. существующие adv-ic иконки Offer-страницы),
|
||
НЕ matplotlib и НЕ внешний файл."""
|
||
r = 20.0
|
||
circumference = 2 * 3.14159265358979 * r
|
||
pct = _QUALITY_TIER_PCT.get(confidence, 0.5)
|
||
dashoffset = circumference * (1 - pct)
|
||
color = {"high": _SUCCESS, "medium": _WARN, "low": _DANGER}.get(confidence, _MUTED)
|
||
return (
|
||
f'<svg width="{size}" height="{size}" viewBox="0 0 50 50" '
|
||
f'style="flex-shrink:0;" role="img" aria-label="Качество данных">'
|
||
f'<circle cx="25" cy="25" r="{r:.0f}" fill="none" stroke="{_TRACK}" '
|
||
f'stroke-width="3"></circle>'
|
||
f'<circle cx="25" cy="25" r="{r:.0f}" fill="none" stroke="{color}" '
|
||
f'stroke-width="3" stroke-linecap="round" stroke-dasharray="{circumference:.1f}" '
|
||
f'stroke-dashoffset="{dashoffset:.1f}" transform="rotate(-90 25 25)"></circle>'
|
||
f'<circle cx="25" cy="25" r="11" fill="none" stroke="{_LINE_SOFT}" '
|
||
f'stroke-width="1"></circle>'
|
||
f'<circle cx="25" cy="25" r="1.6" fill="{color}"></circle>'
|
||
f"</svg>"
|
||
)
|
||
|
||
|
||
def _quality_card_html(estimate: AggregatedEstimate) -> str:
|
||
"""«КАЧЕСТВО ДАННЫХ» карточка (Cover) — донат-индикатор + 3 честные метрики
|
||
(источников / достоверность / разброс цен), см. ObjectSummary.tsx. Все числа —
|
||
реальные вычисленные значения (не fabricated), см. _active_source_count/
|
||
_coeff_var_pct/_conf_label. flex-wrap + min-width:0 + overflow-wrap:anywhere —
|
||
метки короткие и bounded, но структура остаётся safe by construction."""
|
||
n_active = _active_source_count(estimate)
|
||
cv = _coeff_var_pct([lot.price_per_m2 for lot in estimate.analogs])
|
||
cv_str = f"{cv}%" if cv is not None else "—"
|
||
gauge = _data_quality_gauge_svg(estimate.confidence)
|
||
return f"""
|
||
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
|
||
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
|
||
{gauge}
|
||
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
|
||
<div style="font-size:{_FS_XS};letter-spacing:0.1em;color:{_MUTED_2};
|
||
text-transform:uppercase;margin-bottom:5pt;">Качество данных</div>
|
||
<div style="display:flex;gap:16pt;flex-wrap:wrap;font-size:{_FS_SM};color:{_MUTED};">
|
||
<span>Источников
|
||
<span class="mono" style="color:{_INK};">{n_active} / {_TOTAL_SOURCES}</span></span>
|
||
<span>Достоверность
|
||
<span style="color:{_INK};">{_conf_label(estimate.confidence)}</span></span>
|
||
<span>Разброс цен
|
||
<span class="mono" style="color:{_ACCENT};">{cv_str}</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── 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_at из оценки; fallback на +30 дней если None.
|
||
expires = (
|
||
estimate.expires_at.date()
|
||
if estimate.expires_at is not None
|
||
else today + dt.timedelta(days=30)
|
||
)
|
||
report_num = _report_number(estimate)
|
||
|
||
# Короткий адрес (для 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 "—"
|
||
|
||
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
|
||
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
|
||
days_range = _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_chart_svg(
|
||
estimate.range_low_rub,
|
||
estimate.range_high_rub,
|
||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||
days_min=days_range[0] if days_range else None,
|
||
days_max=days_range[1] if days_range else None,
|
||
show_days=days_range is not None,
|
||
)
|
||
|
||
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
|
||
# рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price),
|
||
# а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty).
|
||
discount_pct = _discount_pct(estimate)
|
||
if discount_pct is not None:
|
||
advice_discount_text = (
|
||
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
||
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
||
f"ДомКлик и продажи агентств недвижимости"
|
||
)
|
||
else:
|
||
advice_discount_text = (
|
||
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
||
"Росреестр, ДомКлик и продажи агентств недвижимости"
|
||
)
|
||
|
||
deals_bar = _price_range_chart_svg(
|
||
deals_low,
|
||
deals_high,
|
||
sub_label="Диапазон цен по фактическим сделкам",
|
||
)
|
||
|
||
disclaimer_html = ""
|
||
if brand.pdf_disclaimer:
|
||
disclaimer_html = (
|
||
f"<p style='margin-top:10pt;font-size:{_FS_XS};color:{_MUTED};line-height:1.4;"
|
||
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
|
||
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
||
)
|
||
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
|
||
|
||
heading_html = _section_heading(
|
||
"01",
|
||
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
|
||
tag="h1",
|
||
extra_right=_freshness_status_pill(estimate.data_freshness_minutes),
|
||
)
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand, report_num, today)}
|
||
|
||
{heading_html}
|
||
|
||
<!-- Параметры квартиры (dotted-row — ObjectSummary.tsx summary-row идиома) -->
|
||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
|
||
<tr><td class="dotted-row">№ отчета</td>
|
||
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
||
<tr><td class="dotted-row">Дата отчета</td>
|
||
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
||
<tr><td class="dotted-row">Срок действия данных</td>
|
||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||
<tr><td class="dotted-row">Год постройки</td>
|
||
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
|
||
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
|
||
<tr><td class="dotted-row">Этаж / этажность</td>
|
||
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
||
<tr><td class="dotted-row">Площадь</td>
|
||
<td class="bold dotted-row">{_mono(f"{area} м²")}</td></tr>
|
||
<tr><td class="dotted-row">Планировка</td>
|
||
<td class="bold dotted-row">{rooms_label}</td></tr>
|
||
<tr><td class="dotted-row">Состояние ремонта</td>
|
||
<td class="bold dotted-row">{repair_label}</td></tr>
|
||
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
|
||
</table>
|
||
|
||
<!-- Качество данных — донат-индикатор + честные метрики (card-glass/bracket-tr) -->
|
||
{_quality_card_html(estimate)}
|
||
|
||
<!-- Две равновесные цены: ориентир запроса vs ожидаемая цена продажи (#648 S5b) -->
|
||
{_dual_price_block(estimate, brand)}
|
||
|
||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен в объявлениях
|
||
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
||
</p>
|
||
{listings_bar}
|
||
</div>
|
||
|
||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен по фактическим сделкам
|
||
</p>
|
||
{deals_bar}
|
||
</div>
|
||
|
||
<!-- Что важно при оценке -->
|
||
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
|
||
Что важно при оценке стоимости квартиры:
|
||
</h3>
|
||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||
<tr>
|
||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||
<td class="advice-text">{advice_discount_text}</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:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||
padding-top:4pt;overflow-wrap:anywhere;">
|
||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||
</p>
|
||
{disclaimer_html}
|
||
{footer_html}
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
|
||
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
|
||
|
||
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и
|
||
страница объявлений печатали этот выдуманный коридор как измеренный срок
|
||
экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует
|
||
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
|
||
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||
if not days:
|
||
return None
|
||
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
|
||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||
# count «с учётом ремонта» не существует, второе число было идентично n_total.
|
||
|
||
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
|
||
sources_to_show = estimate.sources_used or []
|
||
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_on_market
|
||
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
|
||
days_range = _days_on_market_range(estimate.analogs)
|
||
range_bar = _price_range_chart_svg(
|
||
estimate.range_low_rub,
|
||
estimate.range_high_rub,
|
||
sub_label="Рынок",
|
||
days_min=days_range[0] if days_range else None,
|
||
days_max=days_range[1] if days_range else None,
|
||
show_days=days_range is not None,
|
||
)
|
||
|
||
# Топ-5 примеров (отсортированных по distance)
|
||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||
examples_rows = _examples_rows(top5)
|
||
|
||
report_num = _report_number(estimate)
|
||
today = dt.date.today()
|
||
footer_html = _page_footer(
|
||
brand,
|
||
report_num,
|
||
today,
|
||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
|
||
valid_until=_expires_date(estimate),
|
||
)
|
||
|
||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand, report_num, today)}
|
||
|
||
{heading_html}
|
||
|
||
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
|
||
<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;">{_mono(f"{n_total} шт.")}</td></tr>
|
||
</table>
|
||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||
</td>
|
||
<td style="width:45%;vertical-align:top;">
|
||
<div class="card-glass bracket-tr" style="padding:10pt 12pt 9pt 12pt;
|
||
overflow-wrap:anywhere;">
|
||
<table class="search-params" style="width:100%;border-collapse:collapse;">
|
||
<tr><td class="dotted-row">Расположение</td>
|
||
<td class="bold dotted-row">± 1 км от локации дома</td></tr>
|
||
<tr><td class="dotted-row">Тип дома</td>
|
||
<td class="bold dotted-row">{house_label}</td></tr>
|
||
<tr><td class="dotted-row">Год постройки</td>
|
||
<td class="bold dotted-row">{_mono(year_range)}</td></tr>
|
||
<tr><td class="dotted-row">Диапазон площади</td>
|
||
<td class="bold dotted-row">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
|
||
<tr><td class="dotted-row">Планировка</td>
|
||
<td class="bold dotted-row">{rooms_label}</td></tr>
|
||
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
|
||
</table>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен в объявлениях</p>
|
||
{range_bar}
|
||
|
||
<!-- Примеры аналогов -->
|
||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||
Примеры аналогичных объектов с учётом ремонта
|
||
</h3>
|
||
<table class="examples-table" style="width:100%;border-collapse:collapse;
|
||
table-layout:fixed;margin-top:6pt;">
|
||
{_examples_table_head()}
|
||
<tbody>{examples_rows}</tbody>
|
||
</table>
|
||
|
||
{footer_html}
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
# colgroup общий для examples-table (Listings/Deals) — table-layout:fixed +
|
||
# явные проценты вместо auto-layout, который на длинных адресах мог раздувать
|
||
# таблицу шире полосы набора (переполнение страницы читается как «наложение»).
|
||
_EXAMPLES_COLGROUP = (
|
||
'<colgroup><col style="width:38%;"><col style="width:14%;">'
|
||
'<col style="width:16%;"><col style="width:18%;"><col style="width:14%;"></colgroup>'
|
||
)
|
||
|
||
|
||
def _examples_table_head() -> str:
|
||
"""<colgroup>+<thead> общий для _build_listings_page/_build_deals_page —
|
||
одна и та же структура таблицы примеров на обеих страницах."""
|
||
return f"""{_EXAMPLES_COLGROUP}
|
||
<thead>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Адрес квартиры</th>
|
||
<th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Источник</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость 1 м², руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Стоимость объекта, руб.</th>
|
||
<th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">Срок экспозиции, дней</th>
|
||
</tr>
|
||
</thead>"""
|
||
|
||
|
||
def _examples_rows(lots: list[AnalogLot]) -> str:
|
||
if not lots:
|
||
return (
|
||
"<tr><td colspan='5' class='empty' "
|
||
f"style='text-align:center;padding:14pt;color:{_MUTED};'>Нет данных</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)}' "
|
||
f"style='color:{_ACCENT};text-decoration:none;overflow-wrap:anywhere;'>"
|
||
f"{addr} ↗</a>"
|
||
)
|
||
else:
|
||
addr_cell = addr
|
||
ppm2_cell = _fmt_ppm2(lot.price_per_m2) if lot.price_per_m2 is not None else "—"
|
||
price_cell = _fmt_ppm2(lot.price_rub) if lot.price_rub is not None else "—"
|
||
# #1533: 0 — валидное значение (лот размещён сегодня), `0 or '—'` теряло бы его.
|
||
days_cell = lot.days_on_market if lot.days_on_market is not None else "—"
|
||
# Адрес/источник — data-derived, БЕЗ white-space:nowrap, с overflow-wrap:anywhere
|
||
# (длинный адрес/название ЖК без пробелов не должен раздвигать колонку —
|
||
# см. _EXAMPLES_COLGROUP). ₽/м²/цена/срок — числовые/bounded, nowrap уместен.
|
||
rows.append(
|
||
"<tr>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};"
|
||
f"overflow-wrap:anywhere;'>{addr_cell}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};"
|
||
f"overflow-wrap:anywhere;'>{_source_badge_inline(lot.source)}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
|
||
f"white-space:nowrap;'>{_mono(str(ppm2_cell))}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
|
||
f"white-space:nowrap;'>{_mono(str(price_cell))}</td>"
|
||
f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
|
||
f"white-space:nowrap;'>{_mono(str(days_cell))}</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)
|
||
|
||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||
# который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный
|
||
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
|
||
discount_pct = _discount_pct(estimate)
|
||
if discount_pct is not None:
|
||
deals_discount_text = (
|
||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
|
||
f"объявлениях — по этому объекту на {discount_pct}%"
|
||
)
|
||
else:
|
||
deals_discount_text = (
|
||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
|
||
)
|
||
|
||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
|
||
if not deal_sources:
|
||
deal_sources = [s for s in (estimate.sources_used or [])]
|
||
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
|
||
|
||
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")
|
||
# #1532: маппинг идентичен странице «Листинги» (включая monolith_brick/other),
|
||
# иначе один и тот же «Тип дома» рассинхронизирован между стр.2 и стр.3.
|
||
house_label = (
|
||
{
|
||
"panel": "Эконом до 2000 года",
|
||
"brick": "Кирпич до 2000",
|
||
"monolith": "Современный (монолит)",
|
||
"monolith_brick": "Современный (м-к)",
|
||
"other": "Любой",
|
||
}.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)
|
||
)
|
||
# #1366: НЕ показываем срок экспозиции для бара сделок. actual_deals — это
|
||
# зарегистрированные ДКП Росреестра без days_on_market (поле None), поэтому
|
||
# _days_on_market_range вернул бы хардкод-fallback (4, 118) — выдуманные сроки
|
||
# под заголовком «Диапазон цен по фактическим сделкам». Cover-страница так же
|
||
# намеренно не рисует days для бара сделок (show_days по умолчанию False).
|
||
range_bar = _price_range_chart_svg(
|
||
deals_low,
|
||
deals_high,
|
||
sub_label="Рынок",
|
||
)
|
||
|
||
top5 = estimate.actual_deals[:5]
|
||
examples_rows = _examples_rows(top5)
|
||
|
||
report_num = _report_number(estimate)
|
||
footer_html = _page_footer(
|
||
brand,
|
||
report_num,
|
||
today,
|
||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
|
||
valid_until=_expires_date(estimate),
|
||
)
|
||
|
||
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
|
||
|
||
return f"""
|
||
<div style="page-break-after:always;">
|
||
|
||
{_page_header(brand, report_num, today)}
|
||
|
||
{heading_html}
|
||
|
||
<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;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||
<tr><td style="padding:4pt 0;">Период сделок</td>
|
||
<td class="bold" style="text-align:right;">
|
||
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
|
||
</table>
|
||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||
</td>
|
||
<td style="width:45%;vertical-align:top;">
|
||
<div class="card-glass bracket-tr" style="padding:10pt 12pt 9pt 12pt;
|
||
overflow-wrap:anywhere;">
|
||
<table class="search-params" style="width:100%;border-collapse:collapse;">
|
||
<tr><td class="dotted-row">Расположение</td>
|
||
<td class="bold dotted-row">± 1 км от локации дома</td></tr>
|
||
<tr><td class="dotted-row">Тип дома</td>
|
||
<td class="bold dotted-row">{house_label}</td></tr>
|
||
<tr><td class="dotted-row">Год постройки</td>
|
||
<td class="bold dotted-row">{_mono(year_range)}</td></tr>
|
||
<tr><td class="dotted-row">Диапазон площади</td>
|
||
<td class="bold dotted-row">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
|
||
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
|
||
</table>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||
Диапазон цен по фактическим сделкам</p>
|
||
{range_bar}
|
||
|
||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||
{deals_discount_text}
|
||
</div>
|
||
|
||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||
Примеры аналогичных объектов
|
||
</h3>
|
||
<table class="examples-table" style="width:100%;border-collapse:collapse;
|
||
table-layout:fixed;margin-top:6pt;">
|
||
{_examples_table_head()}
|
||
<tbody>{examples_rows}</tbody>
|
||
</table>
|
||
|
||
{footer_html}
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
||
|
||
|
||
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
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)
|
||
|
||
# Data-consistency fix: sold_price теперь зеркалит cover (_dual_price_block) — берём
|
||
# estimate.expected_sold_price_rub (та же реальная цифра по ДКП Росреестра,
|
||
# что на cover в «Ожидаемая цена продажи»), а не независимый плоский 10%
|
||
# хайркат от медианы. Раньше стр.1 и стр.4 одного и того же PDF могли
|
||
# показывать разные цифры (расхождение 10%+) — тот же класс бага, что и
|
||
# _discount_pct/_dual_price_block graceful fallback ниже.
|
||
sold = estimate.expected_sold_price_rub
|
||
if sold is not None and sold > 0:
|
||
sold_price = sold
|
||
else:
|
||
# Fallback только для старых оценок без sold-данных (None/<=0).
|
||
sold_price = listing_price - int(listing_price * 0.10)
|
||
|
||
rieltor_pct_low, rieltor_pct_high = 2, 5
|
||
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
||
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
||
|
||
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
|
||
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
|
||
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
|
||
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
|
||
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
|
||
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
|
||
rooms = int(input_snapshot.get("rooms", 0) or 0)
|
||
if rooms <= 1:
|
||
rent_multiplier = 1.0
|
||
elif rooms == 2:
|
||
rent_multiplier = 1.35
|
||
elif rooms == 3:
|
||
rent_multiplier = 1.8
|
||
else:
|
||
rent_multiplier = 2.4
|
||
rent_base_low, rent_base_high = 84_000, 135_000
|
||
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
|
||
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
|
||
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
||
juridical_low, juridical_high = 15_000, 50_000
|
||
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
||
# ЕКБ-агрегированного источника), см. sub-caption "ориентировочно" в таблице.
|
||
ads_low, ads_high = 6_000, 45_000
|
||
|
||
total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
|
||
total_high = torg_high + rieltor_high + rent_high + juridical_high + ads_high
|
||
|
||
brand_short = _html.escape(brand.name)
|
||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
||
|
||
report_num = _report_number(estimate)
|
||
today = dt.date.today()
|
||
footer_html = _page_footer(
|
||
brand,
|
||
report_num,
|
||
today,
|
||
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
|
||
"Расчёт носит ориентировочный характер и не является офертой.",
|
||
valid_until=_expires_date(estimate),
|
||
)
|
||
|
||
return f"""
|
||
<div><!-- last page: NO page-break-after -->
|
||
|
||
{_page_header(brand, report_num, today)}
|
||
|
||
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
|
||
|
||
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
||
Структура стоимости сделки</h3>
|
||
|
||
<table class="offer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">
|
||
<colgroup><col style="width:42%;"><col style="width:16%;"><col style="width:42%;"></colgroup>
|
||
<thead>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<th style="text-align:left;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;"></th>
|
||
<th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;overflow-wrap:anywhere;">
|
||
{trade_in_label}
|
||
</th>
|
||
<th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
|
||
font-weight:700;">
|
||
Самостоятельная продажа, руб.
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Торг потенциальных покупателей</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">при стоимости квартиры в объявлении
|
||
{_mono(_fmt_rub(listing_price))}</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_MUTED};">Не применимо</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;color:{_DANGER};">
|
||
от {_mono(f"{_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
|
||
{_mono(f"от {torg_pct_low}–{torg_pct_high}%")}</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Услуги риэлтора при продаже квартиры</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">с минимальным торгом за
|
||
{_mono(_fmt_rub(sold_price))}</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
{_mono(f"{_fmt_rub(rieltor_low)} – {_fmt_rub(rieltor_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
|
||
{_mono(f"от {rieltor_pct_low}–{rieltor_pct_high}%")}</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Аренда после сделки</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">
|
||
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
{_mono(f"{_fmt_rub(rent_low)} – {_fmt_rub(rent_high)}")}
|
||
</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid {_BORDER};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Юридическое сопровождение</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">Проверка документов и подготовка
|
||
договоров</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
от {_mono(f"{_fmt_rub(juridical_low)} – {_fmt_rub(juridical_high)}")}</td>
|
||
</tr>
|
||
<tr style="border-bottom:2pt solid {_BORDER_STRONG};">
|
||
<td style="padding:6pt 4pt;">
|
||
<div class="bold">Расходы на рекламу</div>
|
||
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
|
||
на Циан, Авито, Я.Недвижимости</div>
|
||
</td>
|
||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||
бесплатно</td>
|
||
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
|
||
от {_mono(f"{_fmt_rub(ads_low)} – {_fmt_rub(ads_high)}")}<br>
|
||
<span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;font-style:italic;">
|
||
за 3 месяца, ориентировочно</span>
|
||
</td>
|
||
</tr>
|
||
<tr style="background:{_DANGER_SOFT};">
|
||
<td style="padding:8pt 4pt;font-size:{_FS_LG};font-weight:700;">
|
||
Общие финансовые потери</td>
|
||
<td style="padding:8pt 4pt;text-align:right;"></td>
|
||
<td style="padding:8pt 4pt;text-align:right;font-size:{_FS_LG};font-weight:700;
|
||
color:{_DANGER};">
|
||
от {_mono(f"{_fmt_rub(total_low)} – {_fmt_rub(total_high)}")}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
|
||
самостоятельной продаже квартиры
|
||
</div>
|
||
|
||
<h3 style="margin-top:18pt;font-size:{_FS_MD};font-weight:700;">Преимущества</h3>
|
||
<!-- break-inside:avoid на самом grid-контейнере убран (был избыточен поверх
|
||
break-inside:avoid у .advantage-item — см. verify-фикс) — при нехватке
|
||
места это заставляло ВЕСЬ ряд из 4 карточек разом уезжать на новую страницу
|
||
вместе со всем, что после него (disclaimer/footer), оставляя страницу почти
|
||
пустой. .advantage-item сохраняет свой собственный break-inside:avoid (не
|
||
разрывает карточку изнутри) — снятие внешнего avoid не меняет неделимость
|
||
одной карточки, только позволяет самому grid не требовать себе гарантированно
|
||
непрерывного места как единый блок. Было опробовано 2x2 (2 колонки вместо 4)
|
||
— вдвое уменьшает неделимый кусок ряда, но удваивает суммарную высоту блока
|
||
(2 строки вместо 1), что на практике регрессило нормальный (не «stress»)
|
||
фикстур с новым переносом Offer-страницы, где раньше всё помещалось на одной
|
||
— оставлена 4-колоночная раскладка.-->
|
||
<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="{_ACCENT_2}"
|
||
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="{_ACCENT_2}"
|
||
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="{_ACCENT_2}"
|
||
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="{_ACCENT_2}"
|
||
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>
|
||
|
||
{footer_html}
|
||
|
||
</div>
|
||
"""
|
||
|
||
|
||
# ── CSS ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
||
primary = brand.primary_color if brand else "#1d4ed8"
|
||
return f"""
|
||
@font-face {{
|
||
font-family: 'PT Sans';
|
||
src: url({_PT_SANS_REGULAR_URL}) format('truetype');
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'PT Sans';
|
||
src: url({_PT_SANS_BOLD_URL}) format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'PT Serif';
|
||
src: url({_PT_SERIF_BOLD_URL}) format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'Manrope';
|
||
src: url({_MANROPE_REGULAR_URL}) format('truetype');
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'Manrope';
|
||
src: url({_MANROPE_BOLD_URL}) format('truetype');
|
||
font-weight: 700;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'IBM Plex Mono';
|
||
src: url({_IBM_PLEX_MONO_REGULAR_URL}) format('truetype');
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
}}
|
||
@font-face {{
|
||
font-family: 'IBM Plex Mono';
|
||
src: url({_IBM_PLEX_MONO_MEDIUM_URL}) format('truetype');
|
||
font-weight: 500;
|
||
font-style: normal;
|
||
}}
|
||
@page {{
|
||
size: A4;
|
||
margin: 20mm 18mm 20mm 18mm;
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{
|
||
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
||
font-size: {_FS_MD};
|
||
color: {_INK};
|
||
background: {_PAGE_BG};
|
||
margin: 0;
|
||
padding: 0;
|
||
line-height: 1.35;
|
||
}}
|
||
h1, h2, h3 {{ margin: 0; }}
|
||
h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
|
||
.page {{ page-break-after: always; position: relative; }}
|
||
.page:last-child {{ page-break-after: avoid; }}
|
||
|
||
.bold {{ font-weight: 700; }}
|
||
.mono, .mono * {{
|
||
font-family: 'DejaVu Sans Mono', monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
}}
|
||
|
||
/* Params table (Cover) — border-bottom пришёл из .dotted-row (см. HUD-блок ниже),
|
||
применяется точечно per-<td> в разметке (последняя строка — без разделителя,
|
||
ObjectSummary.tsx идиома). overflow-wrap — значения ячейки (адрес и т.п.)
|
||
data-derived, НЕ nowrap. */
|
||
.params-table td {{
|
||
padding: 2pt 4pt;
|
||
font-size: {_FS_SM};
|
||
overflow-wrap: anywhere;
|
||
}}
|
||
.params-table td:first-child {{
|
||
color: {_MUTED};
|
||
width: 40%;
|
||
}}
|
||
.params-table td:last-child {{
|
||
text-align: right;
|
||
}}
|
||
|
||
/* Search params (Listings/Deals) */
|
||
.search-params td {{
|
||
padding: 4pt 0;
|
||
font-size: {_FS_SM};
|
||
overflow-wrap: anywhere;
|
||
}}
|
||
.search-params td:first-child {{
|
||
color: {_MUTED};
|
||
}}
|
||
.search-params td:last-child {{
|
||
text-align: right;
|
||
}}
|
||
|
||
/* Что важно — advice table */
|
||
.advice-table td {{
|
||
padding: 3.5pt 6pt;
|
||
border-bottom: 1px solid {_BORDER};
|
||
vertical-align: top;
|
||
font-size: {_FS_SM};
|
||
line-height: 1.3;
|
||
}}
|
||
.advice-title {{
|
||
font-weight: 700;
|
||
width: 30%;
|
||
color: {_FG};
|
||
}}
|
||
.advice-text {{
|
||
color: {_MUTED};
|
||
}}
|
||
|
||
/* Examples table (Listings/Deals) — не разрывать <tr> между страницами: длинный
|
||
адрес без break-inside:avoid оставляет badge/цену на одной странице, а адрес
|
||
на следующей (см. verify-фикс, #1531/#1532 таблица). */
|
||
.examples-table tr {{
|
||
break-inside: avoid;
|
||
page-break-inside: avoid;
|
||
}}
|
||
|
||
/* Advantages (Offer) — тёплый accent-2 вместо generic red-tinted кругов */
|
||
.advantage-item {{
|
||
border: 1px solid {_BORDER};
|
||
border-radius: 4pt;
|
||
padding: 8pt;
|
||
text-align: left;
|
||
break-inside: avoid;
|
||
page-break-inside: avoid;
|
||
}}
|
||
.advantage-item .adv-ic {{
|
||
display: block;
|
||
width: 28pt;
|
||
height: 28pt;
|
||
border-radius: 50%;
|
||
background: {_ACCENT_2_SOFT};
|
||
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: {_FS_XS};
|
||
margin-bottom: 3pt;
|
||
color: {primary};
|
||
letter-spacing: -0.01em;
|
||
}}
|
||
.advantage-item .adv-desc {{
|
||
font-size: {_FS_XS};
|
||
color: {_MUTED};
|
||
line-height: 1.3;
|
||
overflow-wrap: anywhere;
|
||
}}
|
||
|
||
/* ── Mera v2 HUD chrome (tokens.ts / ObjectSummary.tsx / HeroBar.tsx) ─────────
|
||
Wired into all 4 page-builders this stage (_build_cover/_build_listings_page/
|
||
_build_deals_page/_build_offer_page + shared _section_heading/_quality_card_html/
|
||
_source_logo_pill/_price_figure_cell) — card-glass+bracket-tr on price/summary
|
||
cards, section-badge on every page heading, status-pill (freshness, Cover),
|
||
source-pill (Listings/Deals), dotted-row (params-table/search-params rows),
|
||
bullet-dot (source list label). */
|
||
|
||
/* Glass card (flattened — no backdrop-filter/blur, see _CARD_BG comment). */
|
||
.card-glass {{
|
||
position: relative;
|
||
background: {_CARD_BG};
|
||
border: 1pt solid {_LINE_2};
|
||
border-radius: 6pt;
|
||
}}
|
||
|
||
/* Corner-bracket accent — small absolutely-positioned marks, NOT clip-path
|
||
(WeasyPrint clip-path support is unreliable). Apply to a `position:relative`
|
||
ancestor; one or more corners per card, see HeroBar.tsx (4 corners) vs
|
||
ObjectSummary.tsx (single top-right corner). */
|
||
.bracket-tl::before, .bracket-tr::before, .bracket-bl::before, .bracket-br::before {{
|
||
content: "";
|
||
position: absolute;
|
||
width: 11pt;
|
||
height: 11pt;
|
||
}}
|
||
.bracket-tl::before {{ top: -1pt; left: -1pt;
|
||
border-top: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }}
|
||
.bracket-tr::before {{ top: -1pt; right: -1pt;
|
||
border-top: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }}
|
||
.bracket-bl::before {{ bottom: -1pt; left: -1pt;
|
||
border-bottom: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }}
|
||
.bracket-br::before {{ bottom: -1pt; right: -1pt;
|
||
border-bottom: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }}
|
||
|
||
/* Numbered mono section badge — "03" next to a tracked-uppercase heading
|
||
(ObjectSummary.tsx). */
|
||
.section-badge {{
|
||
font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', monospace;
|
||
font-size: {_FS_MD};
|
||
color: {_ACCENT};
|
||
}}
|
||
|
||
/* Status pill — dot + uppercase text, soft border, fully rounded
|
||
(ObjectSummary.tsx "АКТУАЛЬНО"). Default tint is success; override color/
|
||
border-color inline for warn/danger variants. */
|
||
.status-pill {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4pt;
|
||
font-size: {_FS_XS};
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: {_SUCCESS};
|
||
border: 1pt solid {_SUCCESS_BORDER};
|
||
border-radius: 20pt;
|
||
padding: 2pt 7pt;
|
||
}}
|
||
.status-pill::before {{
|
||
content: "";
|
||
display: inline-block;
|
||
width: 5pt;
|
||
height: 5pt;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
}}
|
||
|
||
/* Source pill — soft-tinted geometry, replacing today's solid-colour chips
|
||
(_source_logo_pill/_source_badge_inline still emit the old solid style this
|
||
stage — the per-source soft background/text colour is computed there, not
|
||
here; this class only supplies the shared shape/typography). */
|
||
.source-pill {{
|
||
display: inline-block;
|
||
padding: 2pt 8pt;
|
||
border-radius: 10pt;
|
||
font-size: {_FS_XS};
|
||
font-weight: 600;
|
||
border: 1pt solid {_LINE_2};
|
||
}}
|
||
|
||
/* Dotted row separator (ObjectSummary.tsx summary rows). */
|
||
.dotted-row {{
|
||
border-bottom: 1pt dotted {_LINE_DOTTED};
|
||
}}
|
||
|
||
/* Small colored bullet dot before row labels (ObjectSummary.tsx). */
|
||
.bullet-dot {{
|
||
display: inline-block;
|
||
width: 5pt;
|
||
height: 5pt;
|
||
border-radius: 50%;
|
||
background: {_ACCENT};
|
||
vertical-align: middle;
|
||
}}
|
||
"""
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||
"""Страница-заглушка для случаев, когда данных недостаточно (#697/#9).
|
||
|
||
Рендерится вместо числового отчёта при estimate.insufficient_data=True.
|
||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||
"""
|
||
address = _html.escape(input_snapshot.get("address", "—"))
|
||
report_num = _report_number(estimate)
|
||
today = dt.date.today()
|
||
return f"""
|
||
<div>
|
||
{_page_header(brand, report_num, today)}
|
||
<p style="font-size:{_FS_SM};color:{_MUTED};margin:0 0 18pt 0;">
|
||
№ {_mono(report_num)} · {_mono(today.strftime("%d.%m.%Y"))} · {address}
|
||
</p>
|
||
<div style="margin:40pt auto;text-align:center;max-width:320pt;">
|
||
<div style="font-size:32pt;margin-bottom:12pt;color:{_MUTED};">—</div>
|
||
<h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_XL};font-weight:700;
|
||
color:{_FG};margin-bottom:8pt;">
|
||
Недостаточно данных для оценки
|
||
</h2>
|
||
<p style="font-size:{_FS_MD};color:{_MUTED};line-height:1.5;">
|
||
По данному объекту не найдено достаточного количества аналогов
|
||
для формирования надёжной рыночной оценки.
|
||
Пожалуйста, уточните параметры или обратитесь к специалисту.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
|
||
def generate_trade_in_pdf(
|
||
estimate: AggregatedEstimate,
|
||
input_snapshot: dict, # type: ignore[type-arg]
|
||
*,
|
||
brand=None, # type: ignore[no-untyped-def]
|
||
) -> bytes:
|
||
"""Генерирует WeasyPrint PDF отчёт Trade-In (собственная визуальная система).
|
||
|
||
Если estimate.insufficient_data=True — рендерит одностраничный empty-state
|
||
вместо числового отчёта («Недостаточно данных», #697/#9).
|
||
|
||
Pages (normal):
|
||
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.
|
||
# FontConfiguration берём через атрибут (не отдельный `from weasyprint.text.fonts
|
||
# import ...`) — тесты подменяют sys.modules['weasyprint'] плоским MagicMock без
|
||
# реальных субмодулей, а точечный submodule-импорт на моке падает с
|
||
# "'weasyprint' is not a package"; атрибутный доступ работает и на Mock, и на
|
||
# реальном пакете (weasyprint/__init__.py уже импортирует .text.fonts внутри себя).
|
||
import weasyprint
|
||
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,
|
||
)
|
||
|
||
# Sanitize brand colors before inserting into CSS/style attributes (#13).
|
||
brand = Brand(
|
||
slug=brand.slug,
|
||
name=brand.name,
|
||
logo_url=brand.logo_url,
|
||
primary_color=_safe_color(brand.primary_color, "#1d4ed8"),
|
||
accent_color=_safe_color(brand.accent_color, "#f59e0b"),
|
||
footer_text=brand.footer_text,
|
||
pdf_disclaimer=brand.pdf_disclaimer,
|
||
)
|
||
|
||
# #697/#9: insufficient data → empty-state page, NOT fabricated 0-median report.
|
||
if estimate.insufficient_data:
|
||
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
||
else:
|
||
body_html = (
|
||
_build_cover(estimate, input_snapshot, brand)
|
||
+ _build_listings_page(estimate, input_snapshot, brand)
|
||
+ _build_deals_page(estimate, input_snapshot, brand)
|
||
+ _build_offer_page(estimate, input_snapshot, brand)
|
||
)
|
||
|
||
html_str = (
|
||
'<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">'
|
||
f"<title>Trade-In — {_html.escape(input_snapshot.get('address', ''))}</title>"
|
||
f"</head><body>{body_html}</body></html>"
|
||
)
|
||
css_str = _build_css(brand=brand)
|
||
# FontConfiguration обязателен для того, чтобы @font-face (PT Sans/PT Serif, см.
|
||
# _build_css) реально зарегистрировались и использовались при layout — без него
|
||
# WeasyPrint молча игнорирует @font-face и подставляет fallback из font-stack
|
||
# (DejaVu). Один и тот же объект передаётся в CSS(...) и write_pdf(...).
|
||
font_config = weasyprint.text.fonts.FontConfiguration()
|
||
# SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы.
|
||
# base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:).
|
||
# url_fetcher передаётся в HTML(...) и CSS(...) конструкторы, а НЕ в write_pdf() —
|
||
# в WeasyPrint 69 write_pdf(url_fetcher=...) молча игнорируется
|
||
# ("Unknown rendering option"), а @font-face-ресолвинг внутри CSS(...) происходит
|
||
# синхронно при конструировании, до write_pdf(). Один и тот же fetcher — на оба
|
||
# объекта, иначе guard не покрывает CSS-ресурсы (сейчас только data:-шрифты, но
|
||
# это тот путь, по которому в будущем может прилететь brand-specific URL).
|
||
url_fetcher = _make_safe_url_fetcher()
|
||
pdf_bytes = HTML(string=html_str, base_url=None, url_fetcher=url_fetcher).write_pdf(
|
||
stylesheets=[CSS(string=css_str, font_config=font_config, url_fetcher=url_fetcher)],
|
||
font_config=font_config,
|
||
)
|
||
logger.info(
|
||
"PDF generated estimate_id=%s brand=%s size=%d insufficient_data=%s",
|
||
estimate.estimate_id,
|
||
brand.slug,
|
||
len(pdf_bytes),
|
||
estimate.insufficient_data,
|
||
)
|
||
return pdf_bytes
|