gendesign/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
bot-backend 27d1b09c72
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m0s
fix(trade-in-pdf): убрать три ложных утверждения из PDF-отчёта
Отчёт печатал измерения, которых не делал:

1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
   отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
   (4, 118), а обложка и страница объявлений рисовали его как
   измеренный срок экспозиции. Теперь функция возвращает None, и оба
   call-site'а не рисуют срок (show_days=False) — как уже делает
   страница сделок.

2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
   Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
   футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
   …); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».

3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
   же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
   ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
   нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
   хвост «(Екатеринбург, 2026)».

Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
2026-07-12 22:04:17 +03:00

2227 lines
113 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PDF-генератор для Trade-In Estimator — собственная визуальная система 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'&#8722;{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))} &#8211; {_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)} &#8381;/м²')}</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),
# а не хардкод «1018%», который противоречил бы вычисленному «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%» на обложке), а не хардкод «1018%»,
# который противоречил бы вычисленному «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, brand) -> str: # type: ignore[no-untyped-def]
median = estimate.median_price_rub
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
# часть — фиксированные диапазоны рынка ЕКБ (риелтор/аренда/юрист/реклама).
listing_price = median # цена в объявлении
torg_pct_low, torg_pct_high = 5, 15
torg_low = int(listing_price * torg_pct_low / 100)
torg_high = int(listing_price * torg_pct_high / 100)
# 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 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
rent_low, rent_high = 84_000, 135_000
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
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};">однокомнатной квартиры на 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, 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