feat(sf-b7): GET /parcels/{cad}/snapshot.pdf — 1-page WeasyPrint export #334

Merged
lekss361 merged 1 commit from feat/sf-b7-snapshot-pdf into main 2026-05-17 21:11:04 +00:00
5 changed files with 646 additions and 0 deletions

View file

@ -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"'},
)

View file

@ -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

View file

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Карточка участка {{ cad_num }}</title>
<style>
@font-face {
font-family: 'DejaVu Sans';
src: url('{{ font_url }}') format('truetype');
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
color: #1a1a2e;
background: #ffffff;
padding: 20mm 18mm 18mm 18mm;
}
/* ── HEADER ── */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #2563eb;
padding-bottom: 8px;
margin-bottom: 14px;
}
.header-left h1 {
font-size: 14pt;
font-weight: bold;
color: #2563eb;
}
.header-left .subtitle {
font-size: 9pt;
color: #64748b;
margin-top: 2px;
}
.header-right {
text-align: right;
font-size: 8pt;
color: #64748b;
}
/* ── SECTION TITLE ── */
.section-title {
font-size: 10pt;
font-weight: bold;
color: #1e3a5f;
background: #eff6ff;
padding: 4px 8px;
border-left: 3px solid #2563eb;
margin-bottom: 8px;
}
/* ── KPI GRID ── */
.kpi-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.kpi-card {
flex: 1 1 140px;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 7px 10px;
background: #f8fafc;
}
.kpi-card .kpi-label {
font-size: 7.5pt;
color: #64748b;
margin-bottom: 2px;
}
.kpi-card .kpi-value {
font-size: 11pt;
font-weight: bold;
color: #1e3a5f;
}
/* ── TABLE ── */
table {
width: 100%;
border-collapse: collapse;
font-size: 8.5pt;
margin-bottom: 14px;
}
table thead tr th {
background: #1e3a5f;
color: #ffffff;
padding: 5px 8px;
text-align: left;
font-weight: bold;
}
table tbody tr:nth-child(even) td {
background: #f1f5f9;
}
table tbody tr td {
padding: 4px 8px;
border-bottom: 1px solid #e2e8f0;
color: #1a1a2e;
}
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 10px;
font-size: 7.5pt;
font-weight: bold;
}
.badge-green { background: #dcfce7; color: #166534; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.badge-blue { background: #dbeafe; color: #1e40af; }
/* ── FOOTER ── */
.footer {
position: fixed;
bottom: 12mm;
left: 18mm;
right: 18mm;
border-top: 1px solid #cbd5e1;
padding-top: 5px;
display: flex;
justify-content: space-between;
font-size: 7.5pt;
color: #94a3b8;
}
.disclaimer {
font-size: 7pt;
color: #94a3b8;
margin-top: 4px;
font-style: italic;
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<h1>GenDesign &mdash; Карточка участка</h1>
<div class="subtitle">Данные НСПД / ЕГРНsource: cad_parcels. Не является официальной выпиской ЕГРН.</div>
</div>
<div class="header-right">
<strong>{{ cad_num }}</strong><br/>
{{ district or '&mdash;' }}<br/>
{{ address or '&mdash;' }}
</div>
</div>
<!-- BLOCK 1: KPI -->
<div class="section-title">Основные характеристики</div>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Площадь</div>
<div class="kpi-value">{{ area_ha }} га</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Кадастровая стоимость</div>
<div class="kpi-value">{{ cadastral_cost }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Категория земель</div>
<div class="kpi-value">{{ land_category or '&mdash;' }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ВРИ</div>
<div class="kpi-value">{{ vri or '&mdash;' }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Последнее обновление</div>
<div class="kpi-value">{{ last_update or '&mdash;' }}</div>
</div>
</div>
<!-- BLOCK 2: Top-7 POI -->
<div class="section-title">Ближайшая инфраструктура (топ-7 по взвешенному баллу)</div>
{% if poi_items %}
<table>
<thead>
<tr>
<th>Категория</th>
<th>Название</th>
<th>Расстояние</th>
<th>Пешком</th>
<th>Балл</th>
</tr>
</thead>
<tbody>
{% for poi in poi_items %}
<tr>
<td>{{ poi.category_ru }}</td>
<td>{{ poi.name or '&mdash;' }}</td>
<td>{{ poi.distance_m }} м</td>
<td>{{ poi.walk_min }} мин</td>
<td><span class="badge badge-blue">{{ poi.weighted_score }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#64748b; font-size:8.5pt; margin-bottom:14px;">POI в радиусе 1 км не найдены.</p>
{% endif %}
<!-- BLOCK 3: Competitors -->
<div class="section-title">Конкуренты в радиусе 3 км (топ {{ competitors|length }})</div>
{% if competitors %}
<table>
<thead>
<tr>
<th>ЖК / Объект</th>
<th>Застройщик</th>
<th>Класс</th>
<th>Квартир</th>
<th>Расстояние</th>
</tr>
</thead>
<tbody>
{% for c in competitors %}
<tr>
<td>{{ c.comm_name or '&mdash;' }}</td>
<td>{{ c.dev_name or '&mdash;' }}</td>
<td>{{ c.obj_class or '&mdash;' }}</td>
<td>{{ c.flat_count or '&mdash;' }}</td>
<td>{{ c.distance_m }} м</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#64748b; font-size:8.5pt; margin-bottom:14px;">Конкурентов в радиусе 3 км не обнаружено.</p>
{% endif %}
<div class="disclaimer">
Не является выпиской из ЕГРН. Данные носят аналитический характер.
Для официальной выписки: rosreestr.gov.ru
</div>
<!-- FOOTER -->
<div class="footer">
<span>gendsgn.ru &mdash; GenDesign Analytics</span>
<span>Сформировано: {{ generated_at }}</span>
</div>
</body>
</html>

View file

@ -22,6 +22,7 @@ dependencies = [
"tenacity>=9.0.0",
"pillow>=10.4.0",
"weasyprint>=62.0",
"jinja2>=3.1.0",
"ezdxf>=1.3.0",
"openpyxl>=3.1.0",
"pandas>=2.2.0",

14
backend/uv.lock generated
View file

@ -568,6 +568,7 @@ dependencies = [
{ name = "geopandas" },
{ name = "httpx" },
{ name = "ijson" },
{ name = "jinja2" },
{ name = "numpy" },
{ name = "openpyxl" },
{ name = "pandas" },
@ -608,6 +609,7 @@ requires-dist = [
{ name = "geopandas", specifier = ">=1.0.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "ijson", specifier = ">=3.2.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", specifier = ">=2.2.0" },
@ -871,6 +873,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"