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