gendesign/backend/app/services/exporters/snapshot_pdf.py
lekss361 4040cba4c0
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
feat(sf-b7): GET /parcels/{cad}/snapshot.pdf — 1-page WeasyPrint export (#334)
2026-05-17 21:11:03 +00:00

204 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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-снимка кадастрового участка.
Использует WeasyPrint + Jinja2. Шрифты — DejaVu Sans из системы (Dockerfile)
или из пакета weasyprint (font fallback). Шаблон: app/templates/parcel_snapshot.html.
"""
import datetime
import logging
import pathlib
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger(__name__)
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates)
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates"
# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
_DEJAVU_CANDIDATES: list[str] = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
]
_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
"kindergarten": "Детский сад",
"pharmacy": "Аптека",
"hospital": "Больница",
"shop_mall": "ТЦ",
"shop_supermarket": "Супермаркет",
"shop_small": "Магазин",
"park": "Парк",
"bus_stop": "Автобус",
"metro_stop": "Метро",
"tram_stop": "Трамвай",
}
# Веса POI-категорий — должны совпадать с _POI_WEIGHTS в parcels.py.
# Дублированы здесь чтобы exporter не импортировал из api-слоя.
_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5,
}
_WALK_SPEED_M_PER_MIN: float = 80.0 # ~5 км/ч
def _find_font_url() -> str:
"""Вернуть file:// URL для DejaVu Sans или пустую строку (system fallback).
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
поэтому пустая строка допустима — шрифт тогда подбирается CSS generic.
"""
for path in _DEJAVU_CANDIDATES:
if pathlib.Path(path).exists():
return f"file://{path}"
logger.warning(
"snapshot_pdf: DejaVu Sans не найден в стандартных путях — используем system fallback"
)
return ""
def _format_cost(value: float | None) -> str:
"""Форматировать кадастровую стоимость в читаемый вид (млн/тыс ₽)."""
if value is None:
return ""
if value >= 1_000_000:
return f"{value / 1_000_000:.1f} млн ₽"
if value >= 1_000:
return f"{value / 1_000:.0f} тыс ₽"
return f"{value:.0f}"
def _build_poi_items(poi_rows: list[dict[str, Any]], limit: int = 7) -> list[dict[str, Any]]:
"""Вычислить weighted_score для каждого POI и вернуть топ-N отсортированных.
Формула: weighted_score = weight * max(0, 1 - distance_m / 1000)
Отрицательные вклады (трамвай) — не включаем в топ-список.
"""
items: list[dict[str, Any]] = []
for p in poi_rows:
cat: str = p.get("category", "")
w = _POI_WEIGHTS.get(cat, 0.0)
distance_m = float(p.get("distance_m") or 0)
decay = max(0.0, 1.0 - distance_m / 1000.0)
score = round(w * decay, 2)
if score <= 0:
continue
walk_min = max(1, round(distance_m / _WALK_SPEED_M_PER_MIN))
items.append(
{
"category_ru": _CATEGORY_RU.get(cat, cat),
"name": p.get("name") or "",
"distance_m": round(distance_m),
"walk_min": walk_min,
"weighted_score": score,
}
)
items.sort(key=lambda x: x["weighted_score"], reverse=True)
return items[:limit]
def generate_snapshot_pdf(
*,
cad_num: str,
address: str | None,
district: str | None,
area_m2: float | None,
cadastral_cost_rub: float | None,
land_category: str | None,
vri: str | None,
last_update: str | None,
poi_rows: list[dict[str, Any]],
competitor_rows: list[dict[str, Any]],
competitors_limit: int = 5,
) -> bytes:
"""Сгенерировать PDF-снимок участка (1 страница A4).
Аргументы:
cad_num: кадастровый номер.
address: адрес из cad_parcels.
district: район города.
area_m2: площадь в кв. м (конвертируем в га для отображения).
cadastral_cost_rub: кадастровая стоимость в рублях.
land_category: категория земель.
vri: вид разрешённого использования.
last_update: строка даты последнего обновления данных.
poi_rows: сырые строки из osm_poi_ekb (category, name, distance_m).
competitor_rows: строки конкурентов из domrf_kn_objects.
competitors_limit: сколько конкурентов выводить (3-5 по ТЗ).
Возвращает: bytes PDF-документа.
"""
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
try:
from weasyprint import HTML
except ImportError as exc:
raise RuntimeError(
"WeasyPrint не установлен. Добавь 'weasyprint>=62.0' в pyproject.toml."
) from exc
env = Environment(
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
template = env.get_template("parcel_snapshot.html")
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else ""
poi_items = _build_poi_items(poi_rows, limit=7)
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
# переупорядочиваем по distance_m для удобства чтения)
competitors_display = sorted(
competitor_rows[:competitors_limit],
key=lambda r: float(r.get("distance_m") or 0),
)
competitors_ctx: list[dict[str, Any]] = [
{
"comm_name": r.get("comm_name"),
"dev_name": r.get("dev_name"),
"obj_class": r.get("obj_class"),
"flat_count": r.get("flat_count"),
"distance_m": round(float(r.get("distance_m") or 0)),
}
for r in competitors_display
]
generated_at = datetime.datetime.now(tz=datetime.UTC).strftime("%d.%m.%Y %H:%M UTC")
html_str = template.render(
cad_num=cad_num,
address=address,
district=district,
area_ha=area_ha,
cadastral_cost=_format_cost(cadastral_cost_rub),
land_category=land_category,
vri=vri,
last_update=last_update or "",
poi_items=poi_items,
competitors=competitors_ctx,
generated_at=generated_at,
font_url=_find_font_url(),
)
logger.info(
"snapshot_pdf: rendering PDF for %s (%d POI, %d competitors)",
cad_num,
len(poi_items),
len(competitors_ctx),
)
pdf_bytes: bytes = HTML(string=html_str, base_url=str(_TEMPLATE_DIR)).write_pdf()
return pdf_bytes