diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 9a315b14..c1b0bb17 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -30,6 +30,7 @@ from app.schemas.parcel import ( RiskZone, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf +from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( cad_exists_in_db, @@ -2532,3 +2533,189 @@ async def get_parcel_best_layouts_pdf( except Exception as exc: logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc) raise HTTPException(status_code=500, detail="Internal server error") from exc + + +@router.get( + "/{cad_num}/snapshot.pdf", + summary="1-page PDF snapshot участка (НСПД + POI + конкуренты)", +) +def parcel_snapshot_pdf( + cad_num: str, + db: Annotated[Session, Depends(get_db)], +) -> Response: + """Генерирует одностраничный PDF-снимок участка (A4). + + Содержимое: + - Header: кадастровый номер, адрес, район, площадь + - Block 1: 5 KPI (площадь, кадастровая стоимость, категория, ВРИ, дата обновления) + - Block 2: Топ-7 POI по взвешенному баллу (из osm_poi_ekb, радиус 1 км) + - Block 3: Топ-5 конкурентов (из domrf_kn_objects, радиус 3 км) + - Footer: gendsgn.ru + дата генерации + + Не является официальной выпиской ЕГРН — только аналитические данные НСПД. + Генерация <2 сек. Открывается в Adobe Reader / Chrome. + """ + # 1) Получить метаданные участка из cad_parcels + parcel_row = ( + db.execute( + text(""" + SELECT readable_address AS address, + land_record_area AS area_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS vri, + cost_value AS cadastral_cost, + updated_at AS last_update + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + + if not parcel_row: + raise HTTPException( + status_code=404, + detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.", + ) + + # 2) Получить геометрию (WKT) для POI / competitor queries + geom_row = ( + db.execute( + text(""" + SELECT ST_AsText(COALESCE( + (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), + (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) + )) AS wkt + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + geom_wkt: str | None = geom_row["wkt"] if geom_row else None + + # 3) POI в радиусе 1 км (только если есть геометрия) + poi_rows: list[dict[str, Any]] = [] + if geom_wkt: + poi_rows = [ + dict(r) + for r in db.execute( + text(""" + SELECT category, + name, + ST_Distance( + p.geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM osm_poi_ekb p + WHERE ST_DWithin( + p.geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 1000 + ) + ORDER BY distance_m ASC + LIMIT 50 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ] + + # 4) Конкуренты в радиусе 3 км (только если есть геометрия) + competitor_rows: list[dict[str, Any]] = [] + if geom_wkt: + competitor_rows = [ + dict(r) + for r in db.execute( + text(""" + WITH latest_obj AS ( + SELECT DISTINCT ON (obj_id) * + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ) + SELECT obj_id, + comm_name, + dev_name, + obj_class, + flat_count, + ST_Distance( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 3000 + ) + ORDER BY flat_count DESC NULLS LAST + LIMIT 20 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .all() + ] + + # 5) Получить district (через пересечение с ekb_districts если есть геом) + district: str | None = None + if geom_wkt: + district_row = ( + db.execute( + text(""" + SELECT d.district_name + FROM ekb_districts d + WHERE ST_Contains(d.geom, ST_Centroid(ST_GeomFromText(:wkt, 4326))) + LIMIT 1 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + if district_row: + district = district_row["district_name"] + + # 6) Форматировать last_update + raw_update = parcel_row["last_update"] + last_update_str: str | None = None + if raw_update is not None: + try: + last_update_str = raw_update.strftime("%d.%m.%Y") + except AttributeError: + last_update_str = str(raw_update)[:10] + + # 7) Сгенерировать PDF + try: + pdf_bytes = generate_snapshot_pdf( + cad_num=cad_num, + address=parcel_row["address"], + district=district, + area_m2=float(parcel_row["area_m2"]) if parcel_row["area_m2"] is not None else None, + cadastral_cost_rub=( + float(parcel_row["cadastral_cost"]) + if parcel_row["cadastral_cost"] is not None + else None + ), + land_category=parcel_row["land_category"], + vri=parcel_row["vri"], + last_update=last_update_str, + poi_rows=poi_rows, + competitor_rows=competitor_rows, + competitors_limit=5, + ) + except Exception as exc: + logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc) + raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc + + cad_safe = cad_num.replace(":", "-") + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="snapshot-{cad_safe}.pdf"'}, + ) diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py new file mode 100644 index 00000000..756e25a6 --- /dev/null +++ b/backend/app/services/exporters/snapshot_pdf.py @@ -0,0 +1,204 @@ +"""Генерация одностраничного 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 diff --git a/backend/app/templates/parcel_snapshot.html b/backend/app/templates/parcel_snapshot.html new file mode 100644 index 00000000..b8e9ae74 --- /dev/null +++ b/backend/app/templates/parcel_snapshot.html @@ -0,0 +1,240 @@ + + +
+ +| Категория | +Название | +Расстояние | +Пешком | +Балл | +
|---|---|---|---|---|
| {{ poi.category_ru }} | +{{ poi.name or '—' }} | +{{ poi.distance_m }} м | +{{ poi.walk_min }} мин | +{{ poi.weighted_score }} | +
POI в радиусе 1 км не найдены.
+{% endif %} + + +| ЖК / Объект | +Застройщик | +Класс | +Квартир | +Расстояние | +
|---|---|---|---|---|
| {{ c.comm_name or '—' }} | +{{ c.dev_name or '—' }} | +{{ c.obj_class or '—' }} | +{{ c.flat_count or '—' }} | +{{ c.distance_m }} м | +
Конкурентов в радиусе 3 км не обнаружено.
+{% endif %} + +