204 lines
7.6 KiB
Python
204 lines
7.6 KiB
Python
"""Генерация одностраничного 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
|