gendesign/backend/app/services/exporters/trade_in_pdf.py
Light1YT c8ab3e8be9
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m28s
Deploy / deploy (push) Successful in 1m44s
fix(analyze): district KeyError 500 in #994 persist + revive by-bbox tests
LIVE BUG (from #994, already merged): the analysis_runs persist call-site in
analyze_parcel extracted district via result_payload["district"]["district_name"]
— but a district dict without that key (real data: partial district lookup)
raised KeyError OUTSIDE the best-effort SAVEPOINT (extraction is at the call
site, not inside persist_analysis_run) → 500 on the LIVE /analyze endpoint.
Fix: .get("district_name") → None instead of raising. Caught by reviving the
analyze test suite (below).

Test-infra (the suite was un-runnable, which is why the bug shipped):
- Lazy-import WeasyPrint in layout_tz_pdf.py + trade_in_pdf.py (matches the
  existing report_pdf.py / snapshot_pdf.py pattern). The eager top-level imports
  made `app.main` (→ parcels/trade_in routers) un-importable on hosts without
  WeasyPrint native libs (e.g. macOS dev), breaking pytest COLLECTION of the
  whole api/v1 suite. WeasyPrint is still imported when a PDF is actually rendered.
- tests/conftest.py: autouse fixture clears app.dependency_overrides after each
  test (anti-leak — a leaked get_db override caused real-DB connection attempts).
- test_parcel_by_bbox.py: rewrite get_db mocking from patch("...get_db") (a no-op
  — FastAPI Depends holds the original ref) to app.dependency_overrides[get_db],
  add explicit X-Authenticated-User header (RBAC gate), patch latest_run_dates,
  + a new test asserting last_analysis_date from a latest run (#994). 5/5 green.

NOTE: reviving collectability exposes PRE-EXISTING rot in other api/v1 suites
(analyze/admin/best_layouts: RBAC-header + stale-assertion/mock drift) — those
are NOT regressions from this PR (they were uncollectable before) and are
tracked separately for a deliberate suite-rehab + CI test-gate effort.

Refs #994.
2026-06-03 18:04:37 +05:00

426 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PDF генератор для отчёта Trade-In Estimator (TI-2).
Структура отчёта — 4 страницы (как у Брусника.Обмен):
1. Cover — шапка + адрес + параметры квартиры + hero-цена
2. Listings — таблица объявлений-аналогов (top 10)
3. Deals — таблица фактических сделок (last 12 мес.)
4. Offer — выкупная стоимость trade-in (placeholder Phase 2)
Pattern: backend/app/services/exporters/layout_tz_pdf.py (WeasyPrint, уже работает).
"""
from __future__ import annotations
import datetime as dt
import html as _html
import logging
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
# ── helpers ──────────────────────────────────────────────────────────────────
def _fmt_rub(value: int) -> str:
"""Форматирование рублей с пробелами-разделителями: 12 500 000 ₽"""
return f"{value:,}".replace(",", " ") + " ₽"
def _fmt_rub_m(value: int) -> str:
"""Форматирование в миллионах: 12,50 млн ₽"""
m = value / 1_000_000
return f"{m:.2f}".replace(".", ",") + " млн ₽"
def _fmt_ppm2(value: int) -> str:
return f"{value:,}".replace(",", " ") + " ₽/м²"
def _conf_color(confidence: str) -> tuple[str, str, str]:
"""(bg, fg, border) для badge уверенности."""
mapping = {
"high": ("#dcfce7", "#15803d", "#86efac"),
"medium": ("#fef9c3", "#a16207", "#fde68a"),
"low": ("#fee2e2", "#b91c1c", "#fca5a5"),
}
return mapping.get(confidence, ("#f3f4f6", "#374151", "#d1d5db"))
def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
if not lots:
return "<tr><td colspan='6' class='empty'>Нет данных</td></tr>"
rows = []
for lot in lots:
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else ""
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else ""
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else ""
label = "Дата сделки" if is_deal else "В продаже"
_ = label # used for header only
rows.append(
"<tr>"
f"<td>{_html.escape(lot.address)}</td>"
f"<td class='num'>{lot.area_m2:.1f}</td>"
f"<td class='num'>{lot.rooms if lot.rooms else 'С'}</td>"
f"<td class='num'>{floor_val}</td>"
f"<td class='num'>{_fmt_rub(lot.price_rub)}</td>"
f"<td class='num'>{date_val} ({dom_val}д.)</td>"
"</tr>"
)
return "".join(rows)
# ── CSS ──────────────────────────────────────────────────────────────────────
def _build_css() -> str:
return """
@page {
size: A4;
margin: 20mm 18mm 20mm 18mm;
}
* { box-sizing: border-box; }
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 10pt;
color: #1a1d23;
margin: 0;
padding: 0;
}
h1 { font-size: 18pt; margin: 0 0 4pt 0; line-height: 1.2; }
h2 { font-size: 13pt; margin: 0 0 8pt 0; border-bottom: 1.5px solid #e6e8ec; padding-bottom: 4pt; }
h3 { font-size: 11pt; margin: 0 0 6pt 0; color: #374151; }
.page { page-break-after: always; }
.page:last-child { page-break-after: avoid; }
/* Cover */
.cover-header {
background: #1d4ed8; color: #fff;
padding: 20pt; border-radius: 6pt; margin-bottom: 16pt;
}
.cover-header h1 { color: #fff; }
.cover-meta { font-size: 9pt; color: rgba(255,255,255,0.8); margin-top: 4pt; }
.hero-price {
font-size: 28pt; font-weight: 800;
font-variant-numeric: tabular-nums; margin: 12pt 0 4pt;
}
.hero-ppm2 { font-size: 12pt; color: #5b6066; font-variant-numeric: tabular-nums; }
.range-bar-wrap { margin: 10pt 0; }
.range-label { font-size: 9pt; color: #9ca3af; }
.badge {
display: inline-block; padding: 4pt 10pt;
border-radius: 6pt; font-size: 9pt; font-weight: 700; margin-top: 8pt;
}
.params-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr;
gap: 8pt; margin-top: 10pt;
}
.param-item .param-label {
font-size: 8pt; color: #9ca3af;
text-transform: uppercase; letter-spacing: 0.03em;
}
.param-item .param-value { font-size: 10pt; font-weight: 600; }
/* Tables */
table { width: 100%; border-collapse: collapse; margin-top: 8pt; font-size: 9pt; }
thead tr { background: #f3f4f6; }
th {
padding: 5pt 7pt; text-align: left; font-weight: 700;
color: #374151; border-bottom: 1.5px solid #d1d5db;
}
td { padding: 5pt 7pt; border-bottom: 1px solid #e6e8ec; vertical-align: top; }
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr:nth-child(even) td { background: #f9fafb; }
td.empty { text-align: center; color: #9ca3af; padding: 14pt; }
/* Offer page */
.offer-table { margin-top: 14pt; }
.offer-table td { font-size: 10pt; }
.offer-total td { font-weight: 700; background: #eff6ff !important; }
.advantages { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-top: 16pt; }
.advantage-item { border: 1px solid #e6e8ec; border-radius: 6pt; padding: 10pt; }
.advantage-item .adv-title { font-weight: 700; font-size: 10pt; margin-bottom: 4pt; }
.advantage-item .adv-desc { font-size: 9pt; color: #5b6066; }
/* Footer */
.footer {
margin-top: 20pt; padding-top: 8pt;
border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af;
}
.source-logos { color: #6b7280; font-size: 8pt; margin-top: 4pt; }
"""
# ── HTML builder ─────────────────────────────────────────────────────────────
def _build_html(estimate: AggregatedEstimate, input_snapshot: dict) -> str: # type: ignore[type-arg]
today = dt.date.today().strftime("%d.%m.%Y")
address = _html.escape(input_snapshot.get("address", ""))
area_m2: float = input_snapshot.get("area_m2", 0)
rooms: int = input_snapshot.get("rooms", 0)
floor: int = input_snapshot.get("floor", 0)
total_floors: int = input_snapshot.get("total_floors", 0)
year_built = input_snapshot.get("year_built")
house_type = input_snapshot.get("house_type")
repair_state = input_snapshot.get("repair_state")
has_balcony = input_snapshot.get("has_balcony")
conf_bg, conf_fg, conf_border = _conf_color(estimate.confidence)
conf_label = _conf_label(estimate.confidence)
rooms_label = "Студия" if rooms == 0 else f"{rooms}-комн."
house_labels = {
"panel": "Панель",
"brick": "Кирпич",
"monolith": "Монолит",
"monolith_brick": "Монолит-кирпич",
"other": "Другое",
}
repair_labels = {
"needs_repair": "Требует ремонта",
"standard": "Стандартный",
"good": "Хороший",
"excellent": "Евроремонт",
}
# Cover params grid items
params = [
("Площадь", f"{area_m2} м²"),
("Комнат", rooms_label),
("Этаж", f"{floor} из {total_floors}"),
]
if year_built:
params.append(("Год постройки", str(year_built)))
if house_type:
params.append(("Тип дома", house_labels.get(house_type, house_type)))
if repair_state:
params.append(("Ремонт", repair_labels.get(repair_state, repair_state)))
if has_balcony is not None:
params.append(("Балкон", "Есть" if has_balcony else "Нет"))
params_html = "".join(
f"""<div class="param-item">
<div class="param-label">{k}</div>
<div class="param-value">{_html.escape(str(v))}</div>
</div>"""
for k, v in params
)
listing_rows = _analog_rows(estimate.analogs, is_deal=False)
deal_rows = _analog_rows(estimate.actual_deals, is_deal=True)
# Trade-in cost table (stub: -7% buyout)
median = estimate.median_price_rub
torg_pct = 7
buyout_pct = 5 # trade-in discount on top
torg = int(median * torg_pct / 100)
buyout_discount = int(median * buyout_pct / 100)
tradein_price = median - torg - buyout_discount
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Trade-In Оценка — {address}</title>
</head>
<body>
<!-- ═══════════════════════════ PAGE 1 — COVER ═══════════════════════════ -->
<div class="page">
<div class="cover-header">
<h1>Оценка квартиры — Trade-In</h1>
<div class="cover-meta">{address}</div>
<div class="cover-meta">Сформировано: {today} · Действительно 24 часа</div>
</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16pt;">
<div>
<div style="font-size:9pt; color:#5b6066; text-transform:uppercase; letter-spacing:0.05em;">
Оценочная стоимость
</div>
<div class="hero-price">{_fmt_rub_m(estimate.median_price_rub)}</div>
<div class="hero-ppm2">{_fmt_ppm2(estimate.median_price_per_m2)}</div>
<div class="range-label" style="margin-top:8pt;">
Диапазон: {_fmt_rub_m(estimate.range_low_rub)}{_fmt_rub_m(estimate.range_high_rub)}
· данные за {estimate.period_months} мес.
</div>
</div>
<div class="badge"
style="background:{conf_bg}; color:{conf_fg}; border:1px solid {conf_border};">
Достоверность: {conf_label}<br>
<span style="font-size:8pt; font-weight:400;">{estimate.n_analogs} аналогов</span>
</div>
</div>
<h3 style="margin-top:16pt;">Параметры объекта</h3>
<div class="params-grid">
{params_html}
</div>
<div class="footer">
<div>GenDesign · Trade-In Estimator · mock-данные (Phase 1 MVP)</div>
<div class="source-logos">Источники данных: Циан · Авито · ДомКлик · Росреестр</div>
</div>
</div>
<!-- ═══════════════════════════ PAGE 2 — LISTINGS ════════════════════════ -->
<div class="page">
<h2>Объявления-аналоги ({len(estimate.analogs)})</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Аналоги из открытых источников (Циан, Авито, ДомКлик) — схожая комнатность,
площадь ±15%, тот же район. Используются как ориентир рыночной цены.
</p>
<table>
<thead>
<tr>
<th>Адрес</th>
<th>Пл., м²</th>
<th>Комн.</th>
<th>Этаж</th>
<th>Цена</th>
<th>Дата / Экспозиция</th>
</tr>
</thead>
<tbody>
{listing_rows}
</tbody>
</table>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 2</div>
</div>
</div>
<!-- ═══════════════════════════ PAGE 3 — DEALS ═══════════════════════════ -->
<div class="page">
<h2>Фактические сделки ({len(estimate.actual_deals)})</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Сделки из Росреестра (ДДУ + переуступка) за последние 12 месяцев — отражают
реальные цены покупки, в отличие от цен предложения.
</p>
<table>
<thead>
<tr>
<th>Адрес</th>
<th>Пл., м²</th>
<th>Комн.</th>
<th>Этаж</th>
<th>Цена сделки</th>
<th>Дата / Срок</th>
</tr>
</thead>
<tbody>
{deal_rows}
</tbody>
</table>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 3</div>
</div>
</div>
<!-- ═══════════════════════════ PAGE 4 — TRADE-IN COST ══════════════════ -->
<div class="page">
<h2>Выкупная стоимость (trade-in)</h2>
<p style="font-size:9pt; color:#6b7280; margin-top:0;">
Ориентировочный расчёт. Финальная цена выкупа согласовывается с менеджером
и зависит от конкретного объекта и условий сделки.
</p>
<table class="offer-table">
<tbody>
<tr>
<td>Рыночная стоимость (медиана)</td>
<td class="num">{_fmt_rub(median)}</td>
</tr>
<tr>
<td>— Торговый дисконт ({torg_pct}%)</td>
<td class="num">{_fmt_rub(torg)}</td>
</tr>
<tr>
<td>— Дисконт за срочность выкупа ({buyout_pct}%)</td>
<td class="num">{_fmt_rub(buyout_discount)}</td>
</tr>
<tr class="offer-total">
<td>Выкупная цена (ориентир)</td>
<td class="num">{_fmt_rub(tradein_price)}</td>
</tr>
</tbody>
</table>
<div style="margin-top:14pt; padding:10pt; background:#eff6ff; border-left:3px solid #1d4ed8;
border-radius:0 4pt 4pt 0; font-size:9pt; color:#1e40af;">
<strong>Важно:</strong> данные получены из mock-источников (MVP Phase 1).
В Phase 2 расчёт будет основан на реальных данных Циан/Авито/Росреестр
для конкретного адреса и подтверждён менеджером.
</div>
<h3 style="margin-top:18pt;">4 преимущества trade-in</h3>
<div class="advantages">
<div class="advantage-item">
<div class="adv-title">Скорость</div>
<div class="adv-desc">Сделка за 24 недели вместо 36 месяцев самостоятельной продажи</div>
</div>
<div class="advantage-item">
<div class="adv-title">Без хлопот</div>
<div class="adv-desc">Показы, торг, документы — всё берёт на себя девелопер</div>
</div>
<div class="advantage-item">
<div class="adv-title">Зачёт в счёт новостройки</div>
<div class="adv-desc">Стоимость квартиры напрямую идёт в оплату нового жилья</div>
</div>
<div class="advantage-item">
<div class="adv-title">Фиксация цены</div>
<div class="adv-desc">Цена новостройки фиксируется на момент подачи заявки</div>
</div>
</div>
<div class="footer">
<div>GenDesign · Trade-In Estimator · стр. 4</div>
<div class="source-logos">
Расчёт носит ориентировочный характер и не является офертой.
</div>
</div>
</div>
</body>
</html>"""
# ── public API ────────────────────────────────────────────────────────────────
def generate_trade_in_pdf(estimate: AggregatedEstimate, input_snapshot: dict) -> bytes: # type: ignore[type-arg]
"""Генерирует 4-страничный WeasyPrint PDF для Trade-In оценки.
Pages:
1. Cover — шапка + адрес + параметры + hero-цена
2. Listings — таблица объявлений-аналогов (top 10)
3. Deals — таблица фактических сделок (last 12 мес.)
4. Offer — выкупная стоимость trade-in + 4 преимущества
Args:
estimate: AggregatedEstimate из БД
input_snapshot: словарь с полями ввода пользователя (address, area_m2, ...)
Returns:
PDF bytes готовые для Response(media_type="application/pdf")
"""
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
from weasyprint import CSS, HTML
html_str = _build_html(estimate, input_snapshot)
css_str = _build_css()
pdf_bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=css_str)])
logger.info(
"Generated trade-in PDF estimate_id=%s pages=4 size=%d bytes",
estimate.estimate_id,
len(pdf_bytes),
)
return pdf_bytes