@ -566,7 +566,7 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark.
return f """
< div style = " margin-top: 20 pt;" >
< div style = " margin-top: 6 pt;" >
{ note_html }
< div style = " border-top:1pt solid {_LINE_SOFT} ;padding-top:8pt;
font - family : { mono_family } ; font - size : { _FS_XS } ; letter - spacing : 0.06 em ;
@ -859,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
)
explainer = (
f ' <p style= " margin: 6pt 0 2pt 0;padding:6 pt 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:4 pt 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: 6 pt 0 0 0;" ><tr> { asking_cell } { sold_cell } </tr></table> '
f ' margin: 3 pt 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 7 pt 12pt;
< div class = " card-glass bracket-tr " style = " margin: 3pt 0 3pt 0;padding:6pt 12pt 5 pt 12pt;
display : flex ; align - items : center ; gap : 12 pt ; 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: 10 pt;" >
< table class = " params-table " style = " width:100 % ;border-collapse:collapse;margin-bottom: 6 pt;" >
< 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: 6 pt 0 2pt 0;font-size:{_FS_MD} ;font-weight:700; " >
< p style = " margin: 3 pt 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: 8 pt;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: 4 pt;" >
< 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 : 4 pt ; overflow - wrap : anywhere ; " >
< p style = " margin-top: 3 pt;font-size:{_FS_XS} ;color: {_MUTED} ;border-top:1pt solid {_LINE} ;
padding - top : 3 pt ; 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 : 20 mm 18 mm 20 mm 18 mm ;
margin : 25 mm 18 mm 19 mm 18 mm ;
@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 )