diff --git a/tradein-mvp/backend/app/services/brand.py b/tradein-mvp/backend/app/services/brand.py index 370ff9f7..9d2ac093 100644 --- a/tradein-mvp/backend/app/services/brand.py +++ b/tradein-mvp/backend/app/services/brand.py @@ -28,7 +28,7 @@ class Brand: _DEFAULT = Brand( slug="generic", - name="Trade-In Estimator", + name="МЕРА", logo_url=None, primary_color="#1d4ed8", accent_color="#f59e0b", 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 6e46660d..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}
@@ -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 "—" repair_label = repair_labels.get(repair_state, "Не указано") if repair_state 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 не рисуем (не выдумываем «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, @@ -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 = "" if brand.pdf_disclaimer: 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"{_html.escape(brand.pdf_disclaimer)}

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

+

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

{listings_bar}
-
-

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

- {deals_bar} -
- -

+

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

-
№ отчета {_mono(report_num)}
Дата отчетадо {_mono(expires.strftime("%d.%m.%Y"))}
Адрес{address}
Год постройки{_mono(str(year_built))}
{year_label}
Тип дома{house_label}
Этаж / этажность {_mono(f"{floor} / {total_floors}")}
+
@@ -1208,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}
""" @@ -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] 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} @@ -1375,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra {examples_rows} - {footer_html} -
""" @@ -1522,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} @@ -1591,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) {examples_rows}
- {footer_html} -
""" @@ -1657,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", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}

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

- {footer_html} +

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

""" @@ -1894,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; @@ -2203,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/services/test_trade_in_pdf_dual_price.py b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py index 4d5cf26e..1dda6b34 100644 --- a/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py +++ b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py @@ -233,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None: assert "Ожидаемая цена продажи" in html # existing sections untouched 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: @@ -243,7 +245,8 @@ def test_build_cover_without_sold_renders_only_asking() -> None: assert "Ожидаемая цена продажи" not in html # existing layout intact 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) ───── 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 "