fix(tradein/pdf): МЕРА branding + 4-page report, no empty pages (#2513)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 54s

This commit is contained in:
lekss361 2026-07-13 18:04:07 +00:00
parent b5477362f0
commit 987dce11bc
4 changed files with 64 additions and 93 deletions

View file

@ -28,7 +28,7 @@ class Brand:
_DEFAULT = Brand( _DEFAULT = Brand(
slug="generic", slug="generic",
name="Trade-In Estimator", name="МЕРА",
logo_url=None, logo_url=None,
primary_color="#1d4ed8", primary_color="#1d4ed8",
accent_color="#f59e0b", accent_color="#f59e0b",

View file

@ -566,7 +566,7 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую # тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark. # градиентную линию-разделитель между строками meta/wordmark.
return f""" return f"""
<div style="margin-top:20pt;"> <div style="margin-top:6pt;">
{note_html} {note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt; <div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em; font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
@ -859,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
) )
explainer = ( explainer = (
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};' f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">' f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. " "<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП " "<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса." "Росреестра, обычно ниже запроса."
@ -869,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
return ( return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;' 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'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f"{explainer}" f"{explainer}"
) )
@ -1018,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
cv_str = f"{cv}%" if cv is not None else "" cv_str = f"{cv}%" if cv is not None else ""
gauge = _data_quality_gauge_svg(estimate.confidence) gauge = _data_quality_gauge_svg(estimate.confidence)
return f""" return f"""
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt; <div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;"> display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
{gauge} {gauge}
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;"> <div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
@ -1086,16 +1086,14 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
house_label = house_labels.get(house_type, "") if house_type else "" house_label = house_labels.get(house_type, "") if house_type else ""
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано" repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "" balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else ""
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
year_label = _mono(str(year_built)) if year_built else ""
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs; # Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty). # если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs) 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( listings_bar = _price_range_chart_svg(
estimate.range_low_rub, estimate.range_low_rub,
estimate.range_high_rub, estimate.range_high_rub,
@ -1121,12 +1119,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
"Росреестр, ДомКлик и продажи агентств недвижимости" "Росреестр, ДомКлик и продажи агентств недвижимости"
) )
deals_bar = _price_range_chart_svg(
deals_low,
deals_high,
sub_label="Диапазон цен по фактическим сделкам",
)
disclaimer_html = "" disclaimer_html = ""
if brand.pdf_disclaimer: if brand.pdf_disclaimer:
disclaimer_html = ( disclaimer_html = (
@ -1134,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>" f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
f"{_html.escape(brand.pdf_disclaimer)}</p>" f"{_html.escape(brand.pdf_disclaimer)}</p>"
) )
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
heading_html = _section_heading( heading_html = _section_heading(
"01", "01",
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ", "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
@ -1146,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<!-- Параметры квартиры (dotted-row ObjectSummary.tsx summary-row идиома) --> <!-- Параметры квартиры (dotted-row ObjectSummary.tsx summary-row идиома) -->
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;"> <table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
<tr><td class="dotted-row"> отчета</td> <tr><td class="dotted-row"> отчета</td>
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr> <td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
<tr><td class="dotted-row">Дата отчета</td> <tr><td class="dotted-row">Дата отчета</td>
@ -1160,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr> <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">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td> <tr><td class="dotted-row">Год постройки</td>
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr> <td class="bold dotted-row">{year_label}</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">{house_label}</td></tr>
<tr><td class="dotted-row">Этаж / этажность</td> <tr><td class="dotted-row">Этаж / этажность</td>
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr> <td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
@ -1180,25 +1168,18 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
{_dual_price_block(estimate, brand)} {_dual_price_block(estimate, brand)}
<div style="break-inside:avoid;page-break-inside:avoid;"> <div style="break-inside:avoid;page-break-inside:avoid;">
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;"> <p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен в объявлениях Диапазон цен в объявлениях
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span> <span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
</p> </p>
{listings_bar} {listings_bar}
</div> </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 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
Что важно при оценке стоимости квартиры: Что важно при оценке стоимости квартиры:
</h3> </h3>
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;"> <table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
<tr> <tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td> <td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">{advice_discount_text}</td> <td class="advice-text">{advice_discount_text}</td>
@ -1208,19 +1189,20 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего <td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего
на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td> на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td>
</tr> </tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table> </table>
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
самостоятельной продаже до 15%») убрана дублирует детальную
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
сохранён полностью. -->
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE}; <p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:4pt;overflow-wrap:anywhere;"> padding-top:3pt;overflow-wrap:anywhere;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id} <strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
</p> </p>
{disclaimer_html} {disclaimer_html}
{footer_html}
</div> </div>
""" """
@ -1309,23 +1291,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5] top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5) 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", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) --> <!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
@ -1375,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tbody>{examples_rows}</tbody> <tbody>{examples_rows}</tbody>
</table> </table>
{footer_html}
</div> </div>
""" """
@ -1522,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
top5 = estimate.actual_deals[:5] top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5) 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", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ") heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
return f""" return f"""
<div style="page-break-after:always;"> <div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html} {heading_html}
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;"> <table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
@ -1591,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<tbody>{examples_rows}</tbody> <tbody>{examples_rows}</tbody>
</table> </table>
{footer_html}
</div> </div>
""" """
@ -1657,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
brand_short = _html.escape(brand.name) brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In" 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""" return f"""
<div><!-- last page: NO page-break-after --> <div><!-- last page: NO page-break-after -->
{_page_header(brand, report_num, today)}
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")} {_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;"> <h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
@ -1838,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
</div> </div>
</div> </div>
{footer_html} <p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
Расчёт носит ориентировочный характер и не является офертой.
</p>
</div> </div>
""" """
@ -1894,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}} }}
@page {{ @page {{
size: A4; size: A4;
margin: 20mm 18mm 20mm 18mm; margin: 25mm 18mm 19mm 18mm;
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}} }}
.running-header {{ position: running(runningHeader); }}
.running-footer {{ position: running(runningFooter); }}
* {{ box-sizing: border-box; }} * {{ box-sizing: border-box; }}
body {{ body {{
font-family: 'PT Sans', 'DejaVu Sans', sans-serif; font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
@ -2203,8 +2151,25 @@ def generate_trade_in_pdf(
if estimate.insufficient_data: if estimate.insufficient_data:
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand) body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
else: else:
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
# документа, а не только там, где явно вставлен в поток секции-билдера.
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
# печатает независимый running-элемент с последнего "видимого" перед разрывом
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
_chrome_report_num = _report_number(estimate)
_chrome_today = dt.date.today()
_chrome_expires = _expires_date(estimate)
chrome_html = (
f'<div class="running-header">'
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
f'<div class="running-footer">'
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
f"</div>"
)
body_html = ( body_html = (
_build_cover(estimate, input_snapshot, brand) chrome_html
+ _build_cover(estimate, input_snapshot, brand)
+ _build_listings_page(estimate, input_snapshot, brand) + _build_listings_page(estimate, input_snapshot, brand)
+ _build_deals_page(estimate, input_snapshot, brand) + _build_deals_page(estimate, input_snapshot, brand)
+ _build_offer_page(estimate, input_snapshot, brand) + _build_offer_page(estimate, input_snapshot, brand)

View file

@ -233,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
assert "Ожидаемая цена продажи" in html assert "Ожидаемая цена продажи" in html
# existing sections untouched # existing sections untouched
assert "Диапазон цен в объявлениях" in html assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html # #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
# (остался на стр. 03) — cover ужат до одной A4-страницы.
assert "Диапазон цен по фактическим сделкам" not in html
def test_build_cover_without_sold_renders_only_asking() -> None: def test_build_cover_without_sold_renders_only_asking() -> None:
@ -243,7 +245,8 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
assert "Ожидаемая цена продажи" not in html assert "Ожидаемая цена продажи" not in html
# existing layout intact # existing layout intact
assert "Диапазон цен в объявлениях" in html assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html # #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
assert "Диапазон цен по фактическим сделкам" not in html
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ───── # ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────

View file

@ -200,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None:
def test_cover_renders_logo_img_when_valid_url() -> None: def test_cover_renders_logo_img_when_valid_url() -> None:
est = _estimate() # #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA) # ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
assert "<img" in html # печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
assert "gk-praktika.ru" in html # trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
# через _page_header, не через _build_cover (симметрично уже существовавшему
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "<img" in header_html
assert "gk-praktika.ru" in header_html
def test_cover_falls_back_to_name_when_logo_none() -> None: def test_cover_falls_back_to_name_when_logo_none() -> None:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
assert "TRADE-IN" in html.upper()
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во # Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как # всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда. # matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date()) header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "TRADE-IN" in header_html.upper()
assert "<img" not in header_html assert "<img" not in header_html