@@ -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}
-
+
| № отчета |
{_mono(report_num)} |
| Дата отчета |
@@ -1160,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
до {_mono(expires.strftime("%d.%m.%Y"))} |
| Адрес | {address} |
| Год постройки |
- {_mono(str(year_built))} |
+ {year_label} |
| Тип дома | {house_label} |
| Этаж / этажность |
{_mono(f"{floor} / {total_floors}")} |
@@ -1180,25 +1168,18 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
{_dual_price_block(estimate, brand)}
-
+
Диапазон цен в объявлениях
(без учета ремонта)
{listings_bar}
-
-
- Диапазон цен по фактическим сделкам
-
- {deals_bar}
-
-
-
+
Что важно при оценке стоимости квартиры:
-
+
| Цены в объявлениях — ожидания собственников |
{advice_discount_text} |
@@ -1208,19 +1189,20 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
Инвестиции в 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'"
+ )
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 "![]()