gendesign/backend/app/services/exporters/full_report_pdf.py
bot-backend 35a03df12b
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 14m44s
fix(full-report): §7 на MultiPolygon + чистка печатного шума §3/§4 (вычитка живого отчёта)
Постраничная вычитка PDF многоконтурного участка 66:41:0702017:131:

1. MultiPolygon роняет concept-стек через geometry._parse_polygon
   (expected Polygon) ДО буфер-стадии, которая мультиконтур уже умеет →
   §7 без концепции, §5 деградирует, market-price падает. Новый
   _largest_polygon_geojson нормализует к крупнейшему контуру (паттерн
   geometry.py:263-265) до ConceptInput; Polygon проходит identity,
   битая геометрия — вход как есть. Интерактивный POST /concepts на
   мультиконтурах по-прежнему 422 — вынесено в #2295.
2. Сиротские заголовки: page-break-after avoid для h2/h3.
3. Тепло/вода §3: агрегат «Суммарно (ЕКБ)», фильтр областного шума
   (Регионгаз по Ирбиту/Тавде и т.п.), кап топ-15/25 + «и ещё K»,
   усечение имён 120 симв; дефицитные ЕКБ-резервы гарантированно
   видимы (bucket до капа, тест-лок).
4. Конкуренты §4: скип безымянных с 0 лотов, группировка корпусов
   (имя+девелопер → min дистанция, сумма лотов, «(K корпусов)»).
5. ЗОУИТ-противоречие: overlaps непусты при has_zouit=false →
   «да (по данным НСПД)»; обратное направление не тронуто.
6. Призрачные НСПД-строки, дубль-колонка confidence, absorption → %,
   средний чек без «+» (unsigned-форматтер).

Refs #2259, #2295
2026-07-04 00:17:01 +05:00

369 lines
19 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-отчёта ПТИЦА (эпик #2259 PR-D).
Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых
на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на
volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`).
ПОТОК (:func:`build_full_report`):
1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет →
ValueError (отчёт без базового анализа бессмысленен).
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → Part B (§4§6)
деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b).
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим.
4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов.
5. концепция — server-side генерация (как POST /concepts): `ConceptInput` из geom
участка (дефолт count=3 comfort) → `geometry.generate`. Best-effort: упала/нет geom
→ отчёт без §7-концепции (§5 деградирует в рыночный контекст).
6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png`
→ `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`).
7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/
forecast_run_id/generated_at/size_bytes).
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native —
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
"""
from __future__ import annotations
import datetime as _dt
import logging
import os
import re
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.analysis_runs.repository import (
ANALYZE_SCHEMA_VERSION,
latest_run_for,
persist_analysis_run,
)
from app.services.exporters.full_report_html import (
MAP_CONCEPT_PLACEHOLDER,
MAP_PARCEL_PLACEHOLDER,
build_full_report_html,
build_full_report_html_part_a,
build_full_report_html_part_b,
)
from app.services.exporters.report_maps import (
embed_map_png,
render_concept_footprint_png,
render_parcel_map_png,
)
logger = logging.getLogger(__name__)
# schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze /
# НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit.
REPORT_SCHEMA_VERSION = "report-pdf-1.0"
# schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
_FORECAST_SCHEMA_VERSION = "1.0"
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
_MAP_MAX_PX = 1400
# Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта
# GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity.
_CONNECTION_CAPACITY_RADIUS_M = 3000
def render_full_report_pdf(html: str) -> bytes:
"""Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint).
Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native — не нужен при
импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец —
`layout_tz_pdf.render_layout_tz_pdf`.
Args:
html: полный HTML-документ (`build_full_report_html`).
Returns:
PDF-байты (готовы к записи файла / FileResponse).
"""
from weasyprint import HTML
pdf_bytes: bytes = HTML(string=html).write_pdf()
logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes))
return pdf_bytes
def _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]:
"""MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть.
Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` +
`_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём
контур с максимальной площадью (тот же приём, что generative-геометрия применяет к
buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга /
не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию).
Args:
geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка).
Returns:
GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`.
"""
geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom
if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon":
return geom
try:
from shapely.geometry import mapping, shape
multi = shape(geom_dict)
if multi.is_empty or not hasattr(multi, "geoms"):
return geom
largest = max(multi.geoms, key=lambda g: g.area)
logger.info(
"build_full_report: multi-contour участок — взят крупнейший контур из %d",
len(list(multi.geoms)),
)
return dict(mapping(largest))
except Exception:
# Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort).
logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon")
return geom
def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
"""Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).
Концепция НЕ персистится — генерируем на лету из геометрии участка с ДЕФОЛТНОЙ
программой (count=3 comfort — три стратегии жадной раскладки). Повторяем поток
`create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по
центроиду → `geometry.generate` → сериализуем `ConceptOutput`. Любая ошибка (нет
geom / вырожденный участок / сбой БД) → None: отчёт валиден без §7-концепции,
§5 деградирует в рыночный контекст.
Args:
db: SQLAlchemy session (для lookup рыночной цены).
analyze: persist-payload analyze-рана (несёт `geom_geojson` участка).
Returns:
Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None.
"""
geom = analyze.get("geom_geojson")
if not isinstance(geom, dict):
logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
return None
# Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel +
# _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет
# ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур —
# ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и
# market-price молча деградируют на любом мультиконтуре.
geom = _largest_polygon_geojson(geom)
try:
# Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
# цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
payload = ConceptInput(parcel_geojson=geom) # дефолт: housing_class="comfort", 3 стратегии
market_price: float | None = None
price_source = "class_norm"
try:
wkt_point = _parcel_centroid_wkt(payload)
market_price, price_source = _lookup_market_price(db, wkt_point)
except Exception as exc:
# Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию.
logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc)
variants = geometry.generate(
payload,
market_price_per_sqm=market_price,
price_source=price_source,
)
result: dict[str, Any] = ConceptOutput(variants=variants).model_dump()
logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants))
return result
except Exception:
# Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции.
logger.exception("build_full_report: генерация концепции упала → §7 без концепции")
return None
def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
"""Дотянуть connection-capacity для §3-резервов (best-effort).
connection-capacity НЕ в persist-payload (ленивый эндпоинт) — тянем `get_connection_capacity`
как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) → None:
§3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения).
"""
try:
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M)
except Exception:
logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов")
return None
def _find_cached_report(
db: Session,
cad: str,
analyze_run_id: int | None,
forecast_run_id: int | None,
) -> dict[str, Any] | None:
"""Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом.
Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на
месте → возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None.
"""
row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION)
if row is None:
return None
result = row.result if isinstance(row.result, dict) else {}
if (
result.get("analyze_run_id") == analyze_run_id
and result.get("forecast_run_id") == forecast_run_id
):
pdf_path = result.get("pdf_path")
if isinstance(pdf_path, str) and os.path.exists(pdf_path):
return result
return None
def _cad_safe(cad: str) -> str:
"""Кад.номер → безопасный компонент имени файла (whitelist).
Кад.номер приходит из URL-path — жёстко ограничиваем алфавит `[0-9:]` (цифры +
разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` → `_`. Так в имя файла
физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция
в путь), даже если path-param окажется мусорным.
"""
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
Оркестрация: analyze-ран (нет → ValueError) → forecast-ран (нет → Part B «нет данных»)
→ cache-check по (analyze_run_id, forecast_run_id) → connection-capacity (§3-резервы,
best-effort) → концепция server-side (best-effort) → HTML (PR-A/B) + карты (PR-C) →
PDF (WeasyPrint) → запись файла на volume + метадата-ран `report-pdf-1.0`.
Args:
db: SQLAlchemy session.
cad: кадастровый номер участка.
Returns:
Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at,
size_bytes} (+ `cache_hit: True` если вернули существующий файл).
Raises:
ValueError: нет analyze-рана для участка (нечего отчётировать).
"""
analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION)
if analyze_row is None:
raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze")
analyze: dict[str, Any] = analyze_row.result or {}
analyze_run_id = int(analyze_row.id)
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
# Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим.
cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id)
if cached is not None:
logger.info(
"build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s",
cad,
analyze_run_id,
forecast_run_id,
cached.get("pdf_path"),
)
return {**cached, "cache_hit": True}
# connection-capacity (§3-резервы) + концепция (§7) — оба best-effort.
connection_capacity = _get_connection_capacity(db, cad)
concept = _generate_concept_result(db, analyze)
# Адрес участка для титула (egrn.address; None → строка адреса не рисуется).
egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {}
address = egrn.get("address") if isinstance(egrn, dict) else None
generated_at = _dt.datetime.now(_dt.UTC)
generated_at_ru = generated_at.strftime("%d.%m.%Y")
# HTML (PR-A/B): §1§3 (+ connection-capacity в §3) и §4§7 (пустой forecast → «нет данных»).
part_a = build_full_report_html_part_a(
analyze, cad=cad, connection_capacity=connection_capacity
)
part_b = build_full_report_html_part_b(forecast, concept, cad=cad)
html = build_full_report_html(
part_a,
part_b,
cad=cad,
address=address if isinstance(address, str) else None,
generated_at=generated_at_ru,
)
# Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful:
# None → embed_map_png ставит плашку «карта недоступна».
parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX)
html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png)
concept_png: bytes | None = None
if isinstance(concept, dict):
variants = concept.get("variants")
if isinstance(variants, list) and variants and isinstance(variants[0], dict):
concept_png = render_concept_footprint_png(
variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX
)
html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png)
pdf_bytes = render_full_report_pdf(html)
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
reports_dir = Path(settings.reports_dir)
reports_dir.mkdir(parents=True, exist_ok=True)
file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf"
pdf_path = reports_dir / file_name
# АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных
# POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes
# мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace
# атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой).
tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp")
tmp_path.write_bytes(pdf_bytes)
os.replace(tmp_path, pdf_path)
size_bytes = len(pdf_bytes)
result: dict[str, Any] = {
"pdf_path": str(pdf_path),
"analyze_run_id": analyze_run_id,
"forecast_run_id": forecast_run_id,
"generated_at": generated_at.isoformat(),
"size_bytes": size_bytes,
}
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
persist_analysis_run(
db,
cad_num=cad,
result=result,
params={"source": "report-pdf-task"},
district=analyze_row.district,
confidence=None,
status="done",
schema_version=REPORT_SCHEMA_VERSION,
created_by=None,
)
logger.info(
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
cad,
pdf_path,
size_bytes,
analyze_run_id,
forecast_run_id,
)
return result