From 42a50cf8149d6741f5353bde8f25b8a2e5c8d570 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 21:01:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/pdf):=20running=20@page=20header/fo?= =?UTF-8?q?oter=20=E2=86=92=20=D1=80=D0=BE=D0=B2=D0=BD=D0=BE=204=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20(=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header/footer сидели В ПОТОКЕ каждой секции (~110pt/стр.) → cover переполнялся (advice-таблица уезжала на почти-пустую стр. 2), footer listings-секции сиротел на полностью пустую стр. 4 → отчёт 6 страниц вместо 4. Fix: header+footer вынесены в running @page margin-boxes (position:running() + @top-center/@bottom-center) — рендерятся в полях каждой страницы, не занимая поток. Chrome строится ОДИН раз в generate_trade_in_pdf (else-ветка, не insufficient_data) и оборачивается в .running-header/.running-footer; из 4 билдеров inline-вызовы header/footer убраны + очищены осиротевшие переменные. Top-margin 20→25mm под running-header. Дисклеймер offer-страницы перенесён из footer page_note в inline

(сохранён). Убрана дубль-строка advice на cover («до 15% при самопродаже» — детально расписана на стр. 04). Верифицировано рендером реального WeasyPrint 69 в прод-образе: ровно 4 чистые страницы, бренд МЕРА в шапке/футере всех страниц, «Год постройки —», дисклеймер на месте. ruff clean; test_pdf_security 32/32; test_trade_in_pdf_dual_price 25/25. --- .../app/services/exporters/trade_in_pdf.py | 108 +++++++----------- .../backend/tests/test_pdf_security.py | 17 +-- 2 files changed, 54 insertions(+), 71 deletions(-) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 08cb4ac3..425dda5f 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -566,7 +566,7 @@ def _page_footer( # тела страницы) и был источником сложности; заменён на простую тонкую # градиентную линию-разделитель между строками meta/wordmark. return f""" -

+
{note_html}
' + f'

' "Запрос — по чему выставлены сопоставимые квартиры в объявлениях. " "Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП " "Росреестра, обычно ниже запроса." @@ -869,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno return ( f'{asking_cell}{sold_cell}
' + f'margin:3pt 0 0 0;">{asking_cell}{sold_cell}' f"{explainer}" ) @@ -1018,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str: cv_str = f"{cv}%" if cv is not None else "—" gauge = _data_quality_gauge_svg(estimate.confidence) return f""" -

{gauge}
@@ -1126,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"{_html.escape(brand.pdf_disclaimer)}

" ) - footer_html = _page_footer(brand, report_num, today, valid_until=expires) - heading_html = _section_heading( "01", "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ", @@ -1138,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s return f"""
- {_page_header(brand, report_num, today)} - {heading_html} - +
@@ -1172,7 +1168,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_dual_price_block(estimate, brand)}
-

+

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

@@ -1180,10 +1176,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
-

+

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

-
№ отчета {_mono(report_num)}
Дата отчета
+
@@ -1193,19 +1189,20 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s - - - -
Цены в объявлениях — ожидания собственников {advice_discount_text}Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры
Неочевидные расходы при самостоятельной продажеПри самостоятельной продаже суммарные расходы могут достигать - до 15% стоимости квартиры (торг, риелтор, нотариус, справки)
+ -

+

Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}

{disclaimer_html} - {footer_html}
""" @@ -1294,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] 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"""
- {_page_header(brand, report_num, today)} - {heading_html} @@ -1360,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra {examples_rows} - {footer_html} -
""" @@ -1507,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) 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"""
- {_page_header(brand, report_num, today)} - {heading_html} @@ -1576,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) {examples_rows}
- {footer_html} -
""" @@ -1642,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) 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"""
- {_page_header(brand, report_num, today)} - {_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}

@@ -1823,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)

- {footer_html} +

+ Расчёт носит ориентировочный характер и не является офертой. +

""" @@ -1879,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def] }} @page {{ 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; }} body {{ font-family: 'PT Sans', 'DejaVu Sans', sans-serif; @@ -2188,8 +2151,25 @@ def generate_trade_in_pdf( if estimate.insufficient_data: body_html = _build_insufficient_data_page(estimate, input_snapshot, brand) 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'
' + f"{_page_header(brand, _chrome_report_num, _chrome_today)}
" + f'" + ) body_html = ( - _build_cover(estimate, input_snapshot, brand) + chrome_html + + _build_cover(estimate, input_snapshot, brand) + _build_listings_page(estimate, input_snapshot, brand) + _build_deals_page(estimate, input_snapshot, brand) + _build_offer_page(estimate, input_snapshot, brand) diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index 7e05c92b..a97b017f 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -200,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None: def test_cover_renders_logo_img_when_valid_url() -> None: - est = _estimate() - html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA) - assert " None: - est = _estimate() - html = mod._build_cover(est, _SNAPSHOT, _GENERIC) - assert "TRADE-IN" in html.upper() # Logo-specific check via _page_header directly — не проверяем отсутствие во # всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как # matplotlib SVG (см. _price_range_chart_svg), не связанные с логотипом бренда. header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date()) + assert "TRADE-IN" in header_html.upper() assert "