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
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:
parent
b5477362f0
commit
987dce11bc
4 changed files with 64 additions and 93 deletions
|
|
@ -28,7 +28,7 @@ class Brand:
|
|||
|
||||
_DEFAULT = Brand(
|
||||
slug="generic",
|
||||
name="Trade-In Estimator",
|
||||
name="МЕРА",
|
||||
logo_url=None,
|
||||
primary_color="#1d4ed8",
|
||||
accent_color="#f59e0b",
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ def _page_footer(
|
|||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||||
# градиентную линию-разделитель между строками meta/wordmark.
|
||||
return f"""
|
||||
<div style="margin-top:20pt;">
|
||||
<div style="margin-top:6pt;">
|
||||
{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;
|
||||
|
|
@ -859,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
)
|
||||
|
||||
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;">'
|
||||
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.25;">'
|
||||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||||
"Росреестра, обычно ниже запроса."
|
||||
|
|
@ -869,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
|
||||
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'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||
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"""
|
||||
<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;">
|
||||
{gauge}
|
||||
<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 "—"
|
||||
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)}</p>"
|
||||
)
|
||||
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"""
|
||||
<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;">
|
||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
|
||||
<tr><td class="dotted-row">№ отчета</td>
|
||||
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
||||
<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>
|
||||
<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>
|
||||
<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">{_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)}
|
||||
|
||||
<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>
|
||||
</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 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
|
||||
Что важно при оценке стоимости квартиры:
|
||||
</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>
|
||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</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 тыс. руб. повышают цену объекта всего
|
||||
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
|
||||
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
|
||||
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
|
||||
</tr>
|
||||
</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};
|
||||
padding-top:4pt;overflow-wrap:anywhere;">
|
||||
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||
padding-top:3pt;overflow-wrap:anywhere;">
|
||||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||||
</p>
|
||||
{disclaimer_html}
|
||||
{footer_html}
|
||||
|
||||
</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]
|
||||
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) -->
|
||||
|
|
@ -1375,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tbody>{examples_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -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"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<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>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -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"""
|
||||
<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;">
|
||||
|
|
@ -1838,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{footer_html}
|
||||
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
|
||||
Расчёт носит ориентировочный характер и не является офертой.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
|
@ -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'<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 = (
|
||||
_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)
|
||||
|
|
|
|||
|
|
@ -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) ─────
|
||||
|
|
|
|||
|
|
@ -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 "<img" in html
|
||||
assert "gk-praktika.ru" in html
|
||||
# #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
|
||||
# ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
|
||||
# печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
|
||||
# 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:
|
||||
est = _estimate()
|
||||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||||
assert "TRADE-IN" in html.upper()
|
||||
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
|
||||
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
|
||||
# matplotlib SVG <img> (см. _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 "<img" not in header_html
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue