feat(reports): Celery-джоба полного PDF-отчёта + кэш + 3 endpoint'а (#2259 PR-D) #2287
14 changed files with 1594 additions and 8 deletions
|
|
@ -199,6 +199,14 @@ jobs:
|
||||||
# but other callers may invoke the raw path).
|
# but other callers may invoke the raw path).
|
||||||
chmod +x ops/*.sh 2>/dev/null || true
|
chmod +x ops/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
# Full PDF report bind-source (#2259 PR-D). docker создаёт отсутствующий
|
||||||
|
# bind-source как root:root — worker пишет PDF под uid 1000 → PermissionError
|
||||||
|
# → вечный «building». Создаём каталог заранее + chown под контейнерный uid.
|
||||||
|
# chown под non-root deploy-юзером требует sudo → fallback (|| true — если и
|
||||||
|
# sudo нет, каталог уже наш и chown не нужен).
|
||||||
|
mkdir -p reports
|
||||||
|
chown 1000:1000 reports 2>/dev/null || sudo chown 1000:1000 reports 2>/dev/null || true
|
||||||
|
|
||||||
# Sentry release tracking
|
# Sentry release tracking
|
||||||
mkdir -p backend
|
mkdir -p backend
|
||||||
touch backend/.env.runtime
|
touch backend/.env.runtime
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -87,6 +87,10 @@ sf_anton_snapshot.db-*
|
||||||
data/osrm/*
|
data/osrm/*
|
||||||
!data/osrm/.gitkeep
|
!data/osrm/.gitkeep
|
||||||
|
|
||||||
|
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
|
||||||
|
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
|
||||||
|
reports/
|
||||||
|
|
||||||
# Log cruft at repo root
|
# Log cruft at repo root
|
||||||
debug.log
|
debug.log
|
||||||
.ds-sync/
|
.ds-sync/
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from shapely import wkt as _shp_wkt
|
from shapely import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -35,6 +37,8 @@ from app.schemas.parcel import (
|
||||||
ParcelSearchRequest,
|
ParcelSearchRequest,
|
||||||
ParcelSearchResponse,
|
ParcelSearchResponse,
|
||||||
RedLine,
|
RedLine,
|
||||||
|
ReportBuildResponse,
|
||||||
|
ReportStatusResponse,
|
||||||
RiskZone,
|
RiskZone,
|
||||||
UtilityInfrastructureResponse,
|
UtilityInfrastructureResponse,
|
||||||
)
|
)
|
||||||
|
|
@ -46,6 +50,7 @@ from app.services.analysis_runs.repository import (
|
||||||
list_runs_for,
|
list_runs_for,
|
||||||
persist_analysis_run,
|
persist_analysis_run,
|
||||||
)
|
)
|
||||||
|
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
|
||||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||||
from app.services.exporters.report_docx import render_report_docx
|
from app.services.exporters.report_docx import render_report_docx
|
||||||
from app.services.exporters.report_md import (
|
from app.services.exporters.report_md import (
|
||||||
|
|
@ -1556,6 +1561,182 @@ def export_parcel_forecast(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Полный PDF-отчёт ПТИЦА (эпик #2259 PR-D) ─────────────────────────────────
|
||||||
|
# §1–§7 + карты, собирается фоновой Celery-таской и хранится файлом на volume
|
||||||
|
# `/app/reports/` + метадата-строкой в analysis_runs (schema `report-pdf-1.0`).
|
||||||
|
# 3 эндпоинта — enqueue / status / download; пути ≥2-сегментные (рядом с
|
||||||
|
# /forecast/export), не конфликтуют с 1-сегментным /{parcel_id}.
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_or_none(value: Any) -> str | None:
|
||||||
|
"""`datetime` → ISO-строка, иначе None (даты ранов для report-status ответов)."""
|
||||||
|
return value.isoformat() if isinstance(value, _dt.datetime) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_report_result(db: Session, cad_num: str) -> dict[str, Any] | None:
|
||||||
|
"""Метадата готового отчёта (`report-pdf-1.0`), если файл реально на диске.
|
||||||
|
|
||||||
|
Читает последний метадата-ран схемы `report-pdf-1.0`; возвращает его `result`
|
||||||
|
только если `pdf_path` существует на диске (файл могли не смонтировать / удалить).
|
||||||
|
Иначе None (отчёт не готов). Read-only, без исключений наружу (сбой → None).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = latest_run_for(db, cad_num, schema_version=REPORT_SCHEMA_VERSION)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("report status: чтение report-pdf-1.0 рана упало для %s", cad_num)
|
||||||
|
return None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
result = row.result if isinstance(row.result, dict) else {}
|
||||||
|
pdf_path = result.get("pdf_path")
|
||||||
|
if isinstance(pdf_path, str) and Path(pdf_path).exists():
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{cad_num}/report",
|
||||||
|
response_model=ReportBuildResponse,
|
||||||
|
summary="Собрать полный PDF-отчёт участка (§1–§7 + карты) — enqueue (#2259 PR-D)",
|
||||||
|
)
|
||||||
|
def build_parcel_report(
|
||||||
|
cad_num: str,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
response: Response,
|
||||||
|
) -> ReportBuildResponse:
|
||||||
|
"""Поставить в очередь сборку полного PDF-отчёта участка (или вернуть готовый кэш).
|
||||||
|
|
||||||
|
Готовый кэш (метадата-ран `report-pdf-1.0` для последних analyze+forecast ранов +
|
||||||
|
файл на диске) → 200 {status:"ready"} (качать сразу через /report/download). Иначе
|
||||||
|
enqueue фоновой Celery-таски `build_full_report_task(cad)` → 202 {status:"building"}.
|
||||||
|
Best-effort enqueue: провал Celery/Redis НЕ роняет ответ (503 честнее 500, но и он
|
||||||
|
крайне редок — /analyze уже enqueue'ит форсайт тем же брокером).
|
||||||
|
|
||||||
|
В ответе — даты базовых ранов (по каким данным собирается/собран отчёт).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cad_num: кадастровый номер участка.
|
||||||
|
"""
|
||||||
|
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||||
|
if analyze_run is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="нет анализа участка — сначала запустите /analyze"
|
||||||
|
)
|
||||||
|
forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||||
|
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
|
||||||
|
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
|
||||||
|
|
||||||
|
# Готовый кэш → 200 ready (не enqueue'им повторно).
|
||||||
|
if _cached_report_result(db, cad_num) is not None:
|
||||||
|
return ReportBuildResponse(
|
||||||
|
status="ready", analyze_run_at=analyze_at, forecast_run_at=forecast_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue фоновой сборки. Lazy import таски (как forecast в analyze_parcel) — атрибут
|
||||||
|
# резолвится в момент вызова, патчибельно в тестах. Провал брокера → 503 (не 500).
|
||||||
|
try:
|
||||||
|
from app.workers.tasks.full_report import build_full_report_task
|
||||||
|
|
||||||
|
build_full_report_task.delay(cad_num)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("report enqueue failed for %s", cad_num)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="очередь сборки отчёта недоступна, повторите позже"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
response.status_code = 202
|
||||||
|
return ReportBuildResponse(
|
||||||
|
status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{cad_num}/report/status",
|
||||||
|
response_model=ReportStatusResponse,
|
||||||
|
summary="Статус полного PDF-отчёта участка (#2259 PR-D)",
|
||||||
|
)
|
||||||
|
def parcel_report_status(
|
||||||
|
cad_num: str,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> ReportStatusResponse:
|
||||||
|
"""Состояние полного PDF-отчёта: ready / building / none.
|
||||||
|
|
||||||
|
ready — метадата-ран `report-pdf-1.0` есть И файл на диске (качать через
|
||||||
|
/report/download); building — analyze-ран есть, но готового PDF нет (собирается или
|
||||||
|
ещё не запускался POST /report); none — нет даже analyze-рана (отчётировать нечего).
|
||||||
|
Read-only поллинг: сбой чтения → building (не 500), клиент повторит.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cad_num: кадастровый номер участка.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||||
|
forecast_run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("report status: чтение ранов упало для %s", cad_num)
|
||||||
|
return ReportStatusResponse(status="building")
|
||||||
|
|
||||||
|
if analyze_run is None:
|
||||||
|
return ReportStatusResponse(status="none")
|
||||||
|
|
||||||
|
analyze_at = _iso_or_none(getattr(analyze_run, "created_at", None))
|
||||||
|
forecast_at = _iso_or_none(getattr(forecast_run, "created_at", None))
|
||||||
|
|
||||||
|
cached = _cached_report_result(db, cad_num)
|
||||||
|
if cached is not None:
|
||||||
|
return ReportStatusResponse(
|
||||||
|
status="ready",
|
||||||
|
analyze_run_at=analyze_at,
|
||||||
|
forecast_run_at=forecast_at,
|
||||||
|
report_generated_at=cached.get("generated_at"),
|
||||||
|
)
|
||||||
|
return ReportStatusResponse(
|
||||||
|
status="building", analyze_run_at=analyze_at, forecast_run_at=forecast_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{cad_num}/report/download",
|
||||||
|
summary="Скачать готовый полный PDF-отчёт участка (#2259 PR-D)",
|
||||||
|
)
|
||||||
|
def download_parcel_report(
|
||||||
|
cad_num: str,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Отдать готовый PDF-файл полного отчёта (application/pdf).
|
||||||
|
|
||||||
|
Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с
|
||||||
|
Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
|
||||||
|
файл не найден → 404 (сначала POST /report, дождаться status=ready).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cad_num: кадастровый номер участка.
|
||||||
|
"""
|
||||||
|
cached = _cached_report_result(db, cad_num)
|
||||||
|
if cached is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready"
|
||||||
|
)
|
||||||
|
pdf_path = cached["pdf_path"]
|
||||||
|
cad_safe = cad_num.replace(":", "_")
|
||||||
|
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда PDF реально собран), НЕ today: на
|
||||||
|
# cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
|
||||||
|
# build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
|
||||||
|
date_str = _dt.date.today().strftime("%Y-%m-%d")
|
||||||
|
generated_at = cached.get("generated_at")
|
||||||
|
if isinstance(generated_at, str):
|
||||||
|
try:
|
||||||
|
date_str = _dt.datetime.fromisoformat(generated_at).strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("report download: битый generated_at %r для %s", generated_at, cad_num)
|
||||||
|
filename = f"gendesign_report_{cad_safe}_{date_str}.pdf"
|
||||||
|
return FileResponse(
|
||||||
|
pdf_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse)
|
@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse)
|
||||||
def analyze_parcel(
|
def analyze_parcel(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,13 @@ class Settings(BaseSettings):
|
||||||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||||
nspd_lite_rate_ms: int = 600
|
nspd_lite_rate_ms: int = 600
|
||||||
|
|
||||||
|
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
|
||||||
|
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
|
||||||
|
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
|
||||||
|
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
|
||||||
|
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
|
||||||
|
reports_dir: str = "/app/reports"
|
||||||
|
|
||||||
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||||
# На проде монтируется bind-mount-ом docker-compose:
|
# На проде монтируется bind-mount-ом docker-compose:
|
||||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,39 @@ class ConnectionCapacityResponse(BaseModel):
|
||||||
nearby_network_zones: list[NearbyNetworkZone] = []
|
nearby_network_zones: list[NearbyNetworkZone] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full PDF report (эпик #2259 PR-D) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ReportBuildResponse(BaseModel):
|
||||||
|
"""Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).
|
||||||
|
|
||||||
|
status="building" (202) — таска поставлена в очередь, PDF собирается в фоне;
|
||||||
|
status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске,
|
||||||
|
качать сразу через /report/download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["building", "ready"]
|
||||||
|
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
|
||||||
|
analyze_run_at: str | None = None
|
||||||
|
forecast_run_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReportStatusResponse(BaseModel):
|
||||||
|
"""Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).
|
||||||
|
|
||||||
|
status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
|
||||||
|
"building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не
|
||||||
|
запускался), "none" (нет даже analyze-рана — отчётировать нечего).
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["ready", "building", "none"]
|
||||||
|
# Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт.
|
||||||
|
analyze_run_at: str | None = None
|
||||||
|
forecast_run_at: str | None = None
|
||||||
|
# Дата генерации готового PDF (ISO) — только при status="ready".
|
||||||
|
report_generated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,20 @@ def _fmt_pct(fraction: Any) -> str:
|
||||||
return value + "%"
|
return value + "%"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct_raw(value: Any) -> str:
|
||||||
|
"""Процент, УЖЕ выраженный в единицах 0–100 (напр. free_capacity_pct газовой ГРС).
|
||||||
|
|
||||||
|
В отличие от `_fmt_pct` (принимает долю 0..1) — здесь значение уже проценты, НЕ
|
||||||
|
домножаем на 100. 18.4 → «18.4%». Не-число → «—». PURE.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||||
|
return _DASH
|
||||||
|
text_val = f"{float(value):.1f}"
|
||||||
|
if text_val.startswith("-"):
|
||||||
|
return "−" + text_val[1:] + "%"
|
||||||
|
return text_val + "%"
|
||||||
|
|
||||||
|
|
||||||
def _housing_class_ru(value: Any) -> str:
|
def _housing_class_ru(value: Any) -> str:
|
||||||
"""RU-метка класса жилья, иначе значение как есть (str). PURE."""
|
"""RU-метка класса жилья, иначе значение как есть (str). PURE."""
|
||||||
if isinstance(value, str) and value in _HOUSING_CLASS_RU:
|
if isinstance(value, str) and value in _HOUSING_CLASS_RU:
|
||||||
|
|
@ -735,12 +749,101 @@ def _build_alternatives(result: dict[str, Any]) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_section_3(result: dict[str, Any]) -> str:
|
def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
|
||||||
"""§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + альтернативы программы.
|
"""Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом из connection-capacity (#2259 PR-D).
|
||||||
|
|
||||||
NB: «Ресурсные резервы» (ЦП Россетей / Водоканал) и `nearby_network_zones` в
|
connection-capacity НЕ персистится в analyze-payload (ленивый эндпоинт), поэтому его
|
||||||
persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их подтянет
|
результат дотягивает оркестратор PR-D (`get_connection_capacity`) и передаёт сюда
|
||||||
PR-D. Здесь только то, что реально есть в `analyze_result`.
|
отдельным аргументом. Ключи — из `ConnectionCapacityResponse` (schemas/parcel.py):
|
||||||
|
`power_summary`/`power_points`, `water`, `gas`, `heat`, `nearby_network_zones`.
|
||||||
|
None / пустой → блок не рисуется вовсе (пустая строка) — §3 деградирует к тому, что
|
||||||
|
есть в analyze-payload. PURE, graceful: каждый ключ через `.get()`, всё через `_esc`.
|
||||||
|
"""
|
||||||
|
cap = _as_dict(cap)
|
||||||
|
if not cap:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
blocks: list[str] = []
|
||||||
|
|
||||||
|
# Электрика: сводка + ближайшие ЦП с резервом.
|
||||||
|
power_summary = _as_dict(cap.get("power_summary"))
|
||||||
|
nearest = _as_dict(power_summary.get("nearest_with_reserve"))
|
||||||
|
if power_summary or _as_list(cap.get("power_points")):
|
||||||
|
pairs: list[tuple[str, Any]] = [
|
||||||
|
("Центров питания в радиусе", power_summary.get("total_power_points")),
|
||||||
|
]
|
||||||
|
if nearest:
|
||||||
|
unit = nearest.get("reserve_unit") or "МВА"
|
||||||
|
pairs.append((f"Ближайший ЦП с резервом, {unit}", nearest.get("reserve_mva")))
|
||||||
|
pairs.append((" — класс напряжения", nearest.get("voltage_class")))
|
||||||
|
pairs.append((" — расстояние, м", _fmt_int_ru(nearest.get("distance_m"))))
|
||||||
|
pairs = [(k, v) for k, v in pairs if v not in (None, _DASH)]
|
||||||
|
if pairs:
|
||||||
|
blocks.append("<h3>Электроснабжение — свободная мощность</h3>" + _kv_table(pairs))
|
||||||
|
|
||||||
|
# Вода: строки резервов ЦСВ/ЦСК за последний период.
|
||||||
|
water_rows = [
|
||||||
|
[w.get("system_name"), _fmt(w.get("reserve_thousand_m3_day")), w.get("period")]
|
||||||
|
for w in _as_list(cap.get("water"))
|
||||||
|
if isinstance(w, dict)
|
||||||
|
]
|
||||||
|
if water_rows:
|
||||||
|
blocks.append(
|
||||||
|
"<h3>Водоснабжение/водоотведение — резервы</h3>"
|
||||||
|
+ _data_table(["Система", "Резерв, тыс. м³/сут", "Период"], water_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Газ: городские ГРС со свободной мощностью.
|
||||||
|
gas = _as_dict(cap.get("gas"))
|
||||||
|
gas_rows = [
|
||||||
|
[
|
||||||
|
g.get("grs_name"),
|
||||||
|
_fmt(g.get("free_capacity_th_m3_h")),
|
||||||
|
_fmt_pct_raw(g.get("free_capacity_pct")),
|
||||||
|
]
|
||||||
|
for g in _as_list(gas.get("city_grs"))
|
||||||
|
if isinstance(g, dict)
|
||||||
|
]
|
||||||
|
if gas_rows:
|
||||||
|
blocks.append(
|
||||||
|
"<h3>Газоснабжение — свободная мощность ГРС</h3>"
|
||||||
|
+ _data_table(["ГРС", "Свободно, тыс. м³/ч", "Свободно, %"], gas_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Тепло: резервы систем теплоснабжения.
|
||||||
|
heat = _as_dict(cap.get("heat"))
|
||||||
|
heat_rows = [
|
||||||
|
[h.get("org"), h.get("system_name"), _fmt(h.get("reserve_gcal_h")), h.get("period")]
|
||||||
|
for h in _as_list(heat.get("systems"))
|
||||||
|
if isinstance(h, dict)
|
||||||
|
]
|
||||||
|
if heat_rows:
|
||||||
|
blocks.append(
|
||||||
|
"<h3>Теплоснабжение — резервы систем</h3>"
|
||||||
|
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], heat_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Позитив-разрез: сетевые охранные зоны рядом (где физически проходит сеть).
|
||||||
|
zone_rows = [
|
||||||
|
[z.get("human_label"), _fmt_int_ru(z.get("distance_m"))]
|
||||||
|
for z in _as_list(cap.get("nearby_network_zones"))
|
||||||
|
if isinstance(z, dict)
|
||||||
|
]
|
||||||
|
if zone_rows:
|
||||||
|
blocks.append(
|
||||||
|
"<h3>Сети рядом (охранные зоны)</h3>"
|
||||||
|
+ _data_table(["Сеть", "Расстояние, м"], zone_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_section_3(result: dict[str, Any], connection_capacity: dict[str, Any] | None) -> str:
|
||||||
|
"""§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + ресурсные резервы + альтернативы.
|
||||||
|
|
||||||
|
Ресурсные резервы (ЦП Россетей/ЕЭСК, вода, газ, тепло) и `nearby_network_zones` в
|
||||||
|
persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их дотягивает
|
||||||
|
оркестратор PR-D и передаёт через `connection_capacity`. None → блок не рисуется.
|
||||||
"""
|
"""
|
||||||
return f"""
|
return f"""
|
||||||
<div class="section" id="{_ANCHOR_S3}">
|
<div class="section" id="{_ANCHOR_S3}">
|
||||||
|
|
@ -750,6 +853,8 @@ def _build_section_3(result: dict[str, Any]) -> str:
|
||||||
{_build_utilities(result)}
|
{_build_utilities(result)}
|
||||||
|
|
||||||
{_build_engineering_nearby(result)}
|
{_build_engineering_nearby(result)}
|
||||||
|
|
||||||
|
{_build_connection_capacity(connection_capacity)}
|
||||||
</div>
|
</div>
|
||||||
{_build_alternatives(result)}
|
{_build_alternatives(result)}
|
||||||
"""
|
"""
|
||||||
|
|
@ -1313,7 +1418,12 @@ def build_full_report_html_part_b(
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def build_full_report_html_part_a(analyze_result: dict[str, Any], *, cad: str) -> str:
|
def build_full_report_html_part_a(
|
||||||
|
analyze_result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
cad: str,
|
||||||
|
connection_capacity: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
"""Собрать HTML Part A полного отчёта: §1 «Участок» + §2 «Окружение» + §3 «Сети».
|
"""Собрать HTML Part A полного отчёта: §1 «Участок» + §2 «Окружение» + §3 «Сети».
|
||||||
|
|
||||||
PURE (без WeasyPrint / БД / сети). Читает ПЕРСИСТНУТЫЙ `analysis_runs.result`
|
PURE (без WeasyPrint / БД / сети). Читает ПЕРСИСТНУТЫЙ `analysis_runs.result`
|
||||||
|
|
@ -1327,18 +1437,26 @@ def build_full_report_html_part_a(analyze_result: dict[str, Any], *, cad: str) -
|
||||||
Не-dict / None → трактуется как пустой payload (все секции → «нет данных»).
|
Не-dict / None → трактуется как пустой payload (все секции → «нет данных»).
|
||||||
cad: кадастровый номер участка (для логов; в HTML не встраивается напрямую —
|
cad: кадастровый номер участка (для логов; в HTML не встраивается напрямую —
|
||||||
он приходит в каркас через `build_full_report_html`).
|
он приходит в каркас через `build_full_report_html`).
|
||||||
|
connection_capacity: результат `get_connection_capacity` (ЦП/вода/газ/тепло/сети
|
||||||
|
рядом) — НЕ персистится в analyze-payload, дотягивается оркестратором PR-D
|
||||||
|
и рендерится блоком «Ресурсные резервы» в §3. None → блок пропускается.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTML-фрагмент Part A (три `<div class="section">` + опциональный блок альтернатив),
|
HTML-фрагмент Part A (три `<div class="section">` + опциональный блок альтернатив),
|
||||||
готовый как `part_a_html` для `build_full_report_html`.
|
готовый как `part_a_html` для `build_full_report_html`.
|
||||||
"""
|
"""
|
||||||
result = _as_dict(analyze_result)
|
result = _as_dict(analyze_result)
|
||||||
part_a = _build_section_1(result) + _build_section_2(result) + _build_section_3(result)
|
part_a = (
|
||||||
|
_build_section_1(result)
|
||||||
|
+ _build_section_2(result)
|
||||||
|
+ _build_section_3(result, connection_capacity)
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"build_full_report_html_part_a: cad=%s keys=%d has_alternatives=%s",
|
"build_full_report_html_part_a: cad=%s keys=%d has_alternatives=%s has_capacity=%s",
|
||||||
cad,
|
cad,
|
||||||
len(result),
|
len(result),
|
||||||
bool(_as_dict(result.get("program_alternatives")).get("variants")),
|
bool(_as_dict(result.get("program_alternatives")).get("variants")),
|
||||||
|
bool(_as_dict(connection_capacity)),
|
||||||
)
|
)
|
||||||
return part_a
|
return part_a
|
||||||
|
|
||||||
|
|
|
||||||
325
backend/app/services/exporters/full_report_pdf.py
Normal file
325
backend/app/services/exporters/full_report_pdf.py
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
"""Оркестратор полного 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 _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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -68,6 +68,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.supply_layers_refresh",
|
"app.workers.tasks.supply_layers_refresh",
|
||||||
"app.workers.tasks.location_refresh",
|
"app.workers.tasks.location_refresh",
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
|
"app.workers.tasks.full_report",
|
||||||
"app.workers.tasks.ird_harvest",
|
"app.workers.tasks.ird_harvest",
|
||||||
"app.workers.tasks.ekb_krt_sync",
|
"app.workers.tasks.ekb_krt_sync",
|
||||||
"app.workers.tasks.gknspecial_harvest",
|
"app.workers.tasks.gknspecial_harvest",
|
||||||
|
|
|
||||||
80
backend/app/workers/tasks/full_report.py
Normal file
80
backend/app/workers/tasks/full_report.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Celery task: собрать полный PDF-отчёт ПТИЦА в фоне (эпик #2259 PR-D).
|
||||||
|
|
||||||
|
Сборка полного PDF `/site-finder/analysis/{cad}` (§1–§7 + карты) занимает секунды-десятки
|
||||||
|
секунд (WeasyPrint-рендер + OSM-тайлы карт + server-side концепция), поэтому НЕ крутится
|
||||||
|
inline на синхронном эндпоинте — здесь фоновая on-demand таска, оркестрирующая
|
||||||
|
:func:`build_full_report` (full_report_pdf.py) и пишущая PDF-файл + метадата-ран.
|
||||||
|
|
||||||
|
ПОТОК: `SessionLocal()` → `build_full_report(db, cad)` → метадата-dict (pdf_path/…) →
|
||||||
|
close. Best-effort: нет analyze-рана → ValueError внутри оркестратора → ловим, warning,
|
||||||
|
return None (таска не валит воркер — отчётировать нечего). Прочий сбой → logger.exception
|
||||||
|
+ None (карты/концепция/форсайт сами best-effort внутри оркестратора).
|
||||||
|
|
||||||
|
Mirror conventions (forecast.py): `@celery_app.task`, `SessionLocal()` + try/finally close,
|
||||||
|
`logger` (не print), синхронный `def`. Очередь — дефолтная `celery` (как forecast-таска: у
|
||||||
|
неё нет `queue=` в celery_app routing → default; воркер слушает `--queues=celery,scrape_kn,geo`).
|
||||||
|
|
||||||
|
TIME LIMIT: WeasyPrint-рендер + карты могут занять до ~минуты на тяжёлом участке —
|
||||||
|
щедрый per-task лимит (time_limit=600 / soft_time_limit=540), как у forecast-таски.
|
||||||
|
|
||||||
|
REGISTRATION: добавлено в `include=[...]` celery_app.py (зеркало forecast). Beat НЕТ —
|
||||||
|
таска enqueue-on-demand (триггер из POST /{cad}/report).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.exporters.full_report_pdf import build_full_report
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
name="tasks.full_report.build_full_report",
|
||||||
|
time_limit=600,
|
||||||
|
soft_time_limit=540,
|
||||||
|
)
|
||||||
|
def build_full_report_task(self: Any, cad: str) -> dict[str, Any] | None:
|
||||||
|
"""Собрать полный PDF-отчёт участка в фоне и записать файл + метадата-ран.
|
||||||
|
|
||||||
|
Оркестрация целиком в :func:`build_full_report` (full_report_pdf.py): analyze-ран
|
||||||
|
(нет → ValueError → None), forecast-ран (нет → Part B «нет данных»), connection-capacity
|
||||||
|
и концепция best-effort, карты + WeasyPrint-рендер → файл на volume `/app/reports/` +
|
||||||
|
метадата-ран `report-pdf-1.0`. Кэш по (analyze_run_id, forecast_run_id): тот же ключ +
|
||||||
|
файл на месте → cache-hit, не пере-рендерит.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cad: кадастровый номер участка.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Метадата отчёта {pdf_path, analyze_run_id, forecast_run_id, generated_at,
|
||||||
|
size_bytes} (+ `cache_hit` при кэш-хите), либо None если нет analyze-рана /
|
||||||
|
неожиданный сбой (таска failed gracefully, воркер не падает).
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
logger.info("build_full_report_task: старт cad=%s", cad)
|
||||||
|
result = build_full_report(db, cad)
|
||||||
|
logger.info(
|
||||||
|
"build_full_report_task: готово cad=%s path=%s cache_hit=%s",
|
||||||
|
cad,
|
||||||
|
result.get("pdf_path"),
|
||||||
|
result.get("cache_hit", False),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
# Нет analyze-рана — отчётировать нечего (graceful, не crash воркера).
|
||||||
|
logger.warning("build_full_report_task: нечего собирать cad=%s: %s", cad, exc)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Неожиданный сбой рендера/записи: traceback + None (карты/концепция/форсайт
|
||||||
|
# уже best-effort внутри оркестратора — сюда всплывает только фатальное).
|
||||||
|
logger.exception("build_full_report_task failed for cad=%s (returning None)", cad)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
275
backend/tests/api/v1/test_parcel_report.py
Normal file
275
backend/tests/api/v1/test_parcel_report.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""Тесты 3 эндпоинтов полного PDF-отчёта в parcels API (эпик #2259 PR-D).
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
• POST /{cad}/report: нет analyze-рана → 404; enqueue → 202 {status:"building"};
|
||||||
|
готовый кэш (файл на диске) → 200 {status:"ready"} без повторного enqueue;
|
||||||
|
провал брокера → 503.
|
||||||
|
• GET /{cad}/report/status: none (нет analyze) / building (analyze есть, файла нет) /
|
||||||
|
ready (метадата-ран + файл на диске).
|
||||||
|
• GET /{cad}/report/download: файл на диске → 200 application/pdf + attachment;
|
||||||
|
отчёт не готов → 404.
|
||||||
|
|
||||||
|
Стратегия mock (зеркало test_parcels_forecast.py): DB через dependency_overrides,
|
||||||
|
`latest_run_for` патчим, Celery `.delay` — на реальной таске (lazy import в хендлере
|
||||||
|
резолвит атрибут в момент вызова). RBAC обходится settings.testing=True (conftest).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
|
||||||
|
|
||||||
|
_CAD = "66:41:0204016:10"
|
||||||
|
_ANALYZE_SV = "analyze-1.0"
|
||||||
|
_FORECAST_SV = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
|
def _run(run_id: int, result: dict[str, Any] | None = None) -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.id = run_id
|
||||||
|
r.result = result or {}
|
||||||
|
r.district = "Кировский"
|
||||||
|
r.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _lrf_router(rows: dict[str, MagicMock | None]):
|
||||||
|
"""Фабрика side_effect для latest_run_for: row по kwargs['schema_version']."""
|
||||||
|
|
||||||
|
def _side(db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
|
||||||
|
return rows.get(schema_version)
|
||||||
|
|
||||||
|
return _side
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /{cad}/report ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_report_no_analyze_returns_404() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.latest_run_for", side_effect=_lrf_router({_ANALYZE_SV: None})
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "анализа" in resp.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_report_enqueues_and_returns_202() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||||
|
delay_mock = MagicMock()
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||||
|
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||||
|
):
|
||||||
|
# _cached_report_result: report-row None → кэша нет, delay вызывается.
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||||
|
assert resp.status_code == 202, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "building"
|
||||||
|
assert body["analyze_run_at"] is not None
|
||||||
|
delay_mock.assert_called_once_with(_CAD)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
pdf = tmp_path / "report.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-cached")
|
||||||
|
report_row = _run(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(pdf),
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202,
|
||||||
|
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||||
|
"size_bytes": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
delay_mock = MagicMock()
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||||
|
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["status"] == "ready"
|
||||||
|
delay_mock.assert_not_called() # кэш есть → не enqueue'им
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_report_broker_down_returns_503() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||||
|
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||||||
|
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||||||
|
):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||||||
|
assert resp.status_code == 503, resp.text
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /{cad}/report/status ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_none_when_no_analyze() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
rows = {_ANALYZE_SV: None, _FORECAST_SV: None}
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["status"] == "none"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_building_when_analyze_but_no_file() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "building"
|
||||||
|
assert body["analyze_run_at"] is not None
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_ready_when_file_on_disk(tmp_path: Path) -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
pdf = tmp_path / "report.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF")
|
||||||
|
report_row = _run(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(pdf),
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202,
|
||||||
|
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||||
|
"size_bytes": 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ready"
|
||||||
|
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /{cad}/report/download ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_returns_pdf_when_ready(tmp_path: Path) -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
pdf = tmp_path / "report.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.7 payload")
|
||||||
|
# generated_at — ВЧЕРАШНЯЯ дата (cache-hit со вчера): имя файла должно взять ЕЁ,
|
||||||
|
# НЕ today (иначе имя врёт про дату сборки — ревью PR-D п.3).
|
||||||
|
report_row = _run(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(pdf),
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202,
|
||||||
|
"generated_at": "2026-07-01T09:00:00+00:00",
|
||||||
|
"size_bytes": 16,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = {REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
cd = resp.headers["content-disposition"]
|
||||||
|
assert "attachment;" in cd
|
||||||
|
assert cd.endswith('.pdf"')
|
||||||
|
assert f"gendesign_report_{_CAD.replace(':', '_')}_" in cd
|
||||||
|
# Дата в имени — из метадаты (2026-07-01), НЕ today.
|
||||||
|
assert "2026-07-01" in cd
|
||||||
|
assert resp.content == b"%PDF-1.7 payload"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_404_when_not_ready() -> None:
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
rows = {REPORT_SCHEMA_VERSION: None}
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "готов" in resp.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
@ -324,6 +324,68 @@ def test_alternatives_any_viable_true_subtitle() -> None:
|
||||||
assert "+42 млн ₽" in html
|
assert "+42 млн ₽" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8b. connection-capacity в §3 (эпик #2259 PR-D — ресурсные резервы) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_connection_capacity() -> dict[str, Any]:
|
||||||
|
"""Синтетический connection-capacity (структура ConnectionCapacityResponse)."""
|
||||||
|
return {
|
||||||
|
"power_summary": {
|
||||||
|
"total_power_points": 3,
|
||||||
|
"by_load_index": {"open": 2, "closed": 1},
|
||||||
|
"nearest_with_reserve": {
|
||||||
|
"reserve_mva": 12.5,
|
||||||
|
"reserve_unit": "МВА",
|
||||||
|
"voltage_class": "110/10",
|
||||||
|
"distance_m": 640.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"power_points": [{"sc_name": "ПС Тестовая"}],
|
||||||
|
"water": [{"system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": 5.4, "period": "2026-Q2"}],
|
||||||
|
"gas": {
|
||||||
|
"city_grs": [
|
||||||
|
{"grs_name": "ГРС-1", "free_capacity_th_m3_h": 18.0, "free_capacity_pct": 22.3}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"heat": {
|
||||||
|
"systems": [
|
||||||
|
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.1, "period": "2026-Q2"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nearby_network_zones": [
|
||||||
|
{
|
||||||
|
"net_kind": "thermal",
|
||||||
|
"human_label": "Тепловая сеть (по охранной зоне)",
|
||||||
|
"distance_m": 24,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_capacity_block_rendered_in_section_3() -> None:
|
||||||
|
html = build_full_report_html_part_a(
|
||||||
|
_synthetic_result(), cad="X", connection_capacity=_synthetic_connection_capacity()
|
||||||
|
)
|
||||||
|
assert "Электроснабжение — свободная мощность" in html
|
||||||
|
assert "Водоснабжение/водоотведение — резервы" in html
|
||||||
|
assert "Газоснабжение — свободная мощность ГРС" in html
|
||||||
|
assert "Теплоснабжение — резервы систем" in html
|
||||||
|
assert "Сети рядом (охранные зоны)" in html
|
||||||
|
# Значения/подписи присутствуют.
|
||||||
|
assert "110/10" in html # класс напряжения ближайшего ЦП
|
||||||
|
assert "22.3%" in html # free_capacity_pct — уже проценты, НЕ домножены на 100
|
||||||
|
assert "Тепловая сеть (по охранной зоне)" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_capacity_absent_when_none() -> None:
|
||||||
|
"""connection_capacity=None (default) → блок резервов не рисуется (backward-compat)."""
|
||||||
|
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||||
|
assert "Электроснабжение — свободная мощность" not in html
|
||||||
|
# Пустой dict → тоже пропуск.
|
||||||
|
html2 = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity={})
|
||||||
|
assert "Газоснабжение — свободная мощность ГРС" not in html2
|
||||||
|
|
||||||
|
|
||||||
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
|
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
267
backend/tests/services/exporters/test_full_report_pdf.py
Normal file
267
backend/tests/services/exporters/test_full_report_pdf.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""Unit-тесты оркестратора полного PDF-отчёта (эпик #2259 PR-D) — `build_full_report`.
|
||||||
|
|
||||||
|
Без native WeasyPrint и без реальной БД: `render_full_report_pdf` и карты замоканы,
|
||||||
|
DB — MagicMock c подставными analyze/forecast/report ранами. Проверяем оркестрацию и
|
||||||
|
кэш-ключ (analyze_run_id, forecast_run_id):
|
||||||
|
• нет analyze-рана → ValueError;
|
||||||
|
• happy path: рендер вызван, файл записан, метадата-ран (schema report-pdf-1.0) персиснут;
|
||||||
|
• нет forecast-рана → forecast_run_id=None, part_b собран из пустого форсайта (не падает);
|
||||||
|
• cache-hit: тот же (analyze, forecast) ключ + файл на месте → PDF НЕ пере-рендерится;
|
||||||
|
• cache-miss при другом forecast_run_id → пере-рендер;
|
||||||
|
• концепция/connection-capacity best-effort: их сбой не роняет сборку.
|
||||||
|
|
||||||
|
DATABASE_URL до импорта app-модулей (зеркало test_report_pdf.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.exporters import full_report_pdf as mod
|
||||||
|
|
||||||
|
_CAD = "66:41:0204016:10"
|
||||||
|
_GEOJSON = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[[60.6, 56.838], [60.61, 56.838], [60.61, 56.845], [60.6, 56.845], [60.6, 56.838]]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_row(
|
||||||
|
run_id: int, result: dict[str, Any], *, district: str | None = "Кировский"
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Мок Row analysis_runs (id / result / district / created_at)."""
|
||||||
|
row = MagicMock()
|
||||||
|
row.id = run_id
|
||||||
|
row.result = result
|
||||||
|
row.district = district
|
||||||
|
row.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_result() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"cad_num": _CAD,
|
||||||
|
"geom_geojson": _GEOJSON,
|
||||||
|
"egrn": {"address": "ЕКБ, ул. Примерная, 1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _forecast_result() -> dict[str, Any]:
|
||||||
|
return {"schema_version": "1.0", "exec_summary": {"headline": "x"}}
|
||||||
|
|
||||||
|
|
||||||
|
class _RunTable:
|
||||||
|
"""Роутер latest_run_for по schema_version: подставляет заранее заданные раны.
|
||||||
|
|
||||||
|
build_full_report вызывает latest_run_for трижды: analyze, forecast, report-cache
|
||||||
|
(внутри _find_cached_report). Отдаём row по schema_version из словаря (None → нет рана).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rows: dict[str | None, MagicMock | None]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def __call__(self, db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
|
||||||
|
return self._rows.get(schema_version)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_renders(monkeypatch: pytest.MonkeyPatch, pdf_mock: MagicMock) -> None:
|
||||||
|
"""Замокать тяжёлые рендеры: PDF (native) + карты (matplotlib/OSM)."""
|
||||||
|
monkeypatch.setattr(mod, "render_full_report_pdf", pdf_mock)
|
||||||
|
monkeypatch.setattr(mod, "render_parcel_map_png", lambda *a, **k: b"PNGPARCEL")
|
||||||
|
monkeypatch.setattr(mod, "render_concept_footprint_png", lambda *a, **k: b"PNGCONCEPT")
|
||||||
|
# embed_map_png/HTML-сборка PURE — оставляем реальными (проверяют плейсхолдеры).
|
||||||
|
# connection-capacity + концепция — заглушаем на None (best-effort, отдельные тесты ниже).
|
||||||
|
monkeypatch.setattr(mod, "_get_connection_capacity", lambda db, cad: None)
|
||||||
|
monkeypatch.setattr(mod, "_generate_concept_result", lambda db, analyze: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_analyze_run_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Нет analyze-рана → ValueError (отчётировать нечего)."""
|
||||||
|
table = _RunTable({"analyze-1.0": None})
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
with pytest.raises(ValueError, match="analyze"):
|
||||||
|
mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_writes_file_and_persists(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Happy path: PDF отрендерен, файл записан на volume, метадата-ран персиснут."""
|
||||||
|
analyze_row = _make_row(101, _analyze_result())
|
||||||
|
forecast_row = _make_row(202, _forecast_result())
|
||||||
|
table = _RunTable(
|
||||||
|
{"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: None}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||||
|
pdf_mock = MagicMock(return_value=b"%PDF-1.7 fake")
|
||||||
|
_patch_renders(monkeypatch, pdf_mock)
|
||||||
|
persist_mock = MagicMock(return_value=999)
|
||||||
|
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
|
||||||
|
|
||||||
|
result = mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
|
||||||
|
pdf_mock.assert_called_once()
|
||||||
|
assert result["analyze_run_id"] == 101
|
||||||
|
assert result["forecast_run_id"] == 202
|
||||||
|
assert result["size_bytes"] == len(b"%PDF-1.7 fake")
|
||||||
|
assert Path(result["pdf_path"]).exists()
|
||||||
|
assert Path(result["pdf_path"]).read_bytes() == b"%PDF-1.7 fake"
|
||||||
|
# Метадата-ран персиснут с нашей схемой + кэш-ключом.
|
||||||
|
persist_mock.assert_called_once()
|
||||||
|
_, kwargs = persist_mock.call_args
|
||||||
|
assert kwargs["schema_version"] == mod.REPORT_SCHEMA_VERSION
|
||||||
|
assert kwargs["result"]["analyze_run_id"] == 101
|
||||||
|
assert kwargs["result"]["forecast_run_id"] == 202
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_forecast_run_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Нет forecast-рана → forecast_run_id=None, Part B из пустого форсайта (не падает)."""
|
||||||
|
analyze_row = _make_row(101, _analyze_result())
|
||||||
|
table = _RunTable({"analyze-1.0": analyze_row, "1.0": None, mod.REPORT_SCHEMA_VERSION: None})
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||||
|
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
|
||||||
|
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||||
|
|
||||||
|
result = mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
assert result["forecast_run_id"] is None
|
||||||
|
assert result["analyze_run_id"] == 101
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_hit_skips_render(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Тот же (analyze, forecast) ключ + файл на месте → PDF НЕ пере-рендерится."""
|
||||||
|
analyze_row = _make_row(101, _analyze_result())
|
||||||
|
forecast_row = _make_row(202, _forecast_result())
|
||||||
|
# Существующий метадата-ран с тем же ключом + реальный файл.
|
||||||
|
cached_pdf = tmp_path / "cached.pdf"
|
||||||
|
cached_pdf.write_bytes(b"%PDF-cached")
|
||||||
|
report_row = _make_row(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(cached_pdf),
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202,
|
||||||
|
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||||
|
"size_bytes": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
table = _RunTable(
|
||||||
|
{"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
pdf_mock = MagicMock(return_value=b"%PDF-fresh")
|
||||||
|
_patch_renders(monkeypatch, pdf_mock)
|
||||||
|
persist_mock = MagicMock()
|
||||||
|
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
|
||||||
|
|
||||||
|
result = mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
|
||||||
|
pdf_mock.assert_not_called() # cache-hit: не рендерим
|
||||||
|
persist_mock.assert_not_called() # и не персистим повторно
|
||||||
|
assert result["cache_hit"] is True
|
||||||
|
assert result["pdf_path"] == str(cached_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_on_different_forecast_id(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Другой forecast_run_id (свежий форсайт) → cache-miss, пере-рендер."""
|
||||||
|
analyze_row = _make_row(101, _analyze_result())
|
||||||
|
forecast_row = _make_row(303, _forecast_result()) # новый форсайт-ран
|
||||||
|
cached_pdf = tmp_path / "stale.pdf"
|
||||||
|
cached_pdf.write_bytes(b"%PDF-stale")
|
||||||
|
report_row = _make_row(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(cached_pdf),
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202, # СТАРЫЙ forecast id → ключ не совпал
|
||||||
|
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||||
|
"size_bytes": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
table = _RunTable(
|
||||||
|
{"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||||
|
pdf_mock = MagicMock(return_value=b"%PDF-fresh")
|
||||||
|
_patch_renders(monkeypatch, pdf_mock)
|
||||||
|
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||||
|
|
||||||
|
result = mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
pdf_mock.assert_called_once() # ключ разошёлся → пере-рендерим
|
||||||
|
assert result.get("cache_hit") is None
|
||||||
|
assert result["forecast_run_id"] == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_when_file_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Метадата-ран с тем же ключом ЕСТЬ, но файл на диске исчез → пере-рендер."""
|
||||||
|
analyze_row = _make_row(101, _analyze_result())
|
||||||
|
forecast_row = _make_row(202, _forecast_result())
|
||||||
|
report_row = _make_row(
|
||||||
|
500,
|
||||||
|
{
|
||||||
|
"pdf_path": str(tmp_path / "gone.pdf"), # файла нет
|
||||||
|
"analyze_run_id": 101,
|
||||||
|
"forecast_run_id": 202,
|
||||||
|
"generated_at": "2026-07-03T09:00:00+00:00",
|
||||||
|
"size_bytes": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
table = _RunTable(
|
||||||
|
{"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(mod, "latest_run_for", table)
|
||||||
|
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
|
||||||
|
pdf_mock = MagicMock(return_value=b"%PDF-fresh")
|
||||||
|
_patch_renders(monkeypatch, pdf_mock)
|
||||||
|
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
|
||||||
|
|
||||||
|
result = mod.build_full_report(MagicMock(), _CAD)
|
||||||
|
pdf_mock.assert_called_once()
|
||||||
|
assert result.get("cache_hit") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_concept_generation_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Генерация концепции упала → None (отчёт валиден без §7-концепции)."""
|
||||||
|
db = MagicMock()
|
||||||
|
# geometry.generate бросает → _generate_concept_result возвращает None.
|
||||||
|
with patch("app.services.generative.geometry.generate", side_effect=RuntimeError("degenerate")):
|
||||||
|
result = mod._generate_concept_result(db, _analyze_result())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_concept_generation_no_geom_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""analyze-payload без geom_geojson → концепция пропущена (None), без обращения к БД."""
|
||||||
|
assert mod._generate_concept_result(MagicMock(), {"egrn": {}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_capacity_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""get_connection_capacity бросает → None (§3 деградирует, сборка не падает)."""
|
||||||
|
with patch(
|
||||||
|
"app.services.site_finder.connection_capacity_lookup.get_connection_capacity",
|
||||||
|
side_effect=RuntimeError("no geom"),
|
||||||
|
):
|
||||||
|
assert mod._get_connection_capacity(MagicMock(), _CAD) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cad_safe_whitelist() -> None:
|
||||||
|
"""_cad_safe: только `[0-9:]`, `:`→`_`; path-traversal/спецсимволы вырезаны (п.4)."""
|
||||||
|
assert mod._cad_safe("66:41:0204016:10") == "66_41_0204016_10"
|
||||||
|
# Path-traversal / инъекция в путь — всё нецифровое/неколонное вырезается.
|
||||||
|
assert mod._cad_safe("../../etc/passwd") == ""
|
||||||
|
assert mod._cad_safe("66:41/../x") == "66_41"
|
||||||
|
assert mod._cad_safe("66:41:1 ; rm -rf") == "66_41_1"
|
||||||
|
|
@ -137,6 +137,9 @@ services:
|
||||||
# FastAPI middleware и /api/v1/me читают этот файл при старте (см.
|
# FastAPI middleware и /api/v1/me читают этот файл при старте (см.
|
||||||
# app/core/auth.py). Read-only — мутация только через PR.
|
# app/core/auth.py). Read-only — мутация только через PR.
|
||||||
- ./auth/roles.yaml:/app/auth/roles.yaml:ro
|
- ./auth/roles.yaml:/app/auth/roles.yaml:ro
|
||||||
|
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker ПИШЕТ файл сюда, backend его
|
||||||
|
# ЧИТАЕТ для /report/download → общий writable bind-mount на обоих сервисах.
|
||||||
|
- ./reports:/app/reports
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
@ -181,6 +184,9 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # playwright_state.json + photo binaries
|
- ./data:/app/data # playwright_state.json + photo binaries
|
||||||
|
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker (build_full_report_task) ПИШЕТ
|
||||||
|
# PDF сюда, backend ЧИТАЕТ для /report/download → общий writable bind-mount.
|
||||||
|
- ./reports:/app/reports
|
||||||
# Read-only bind-mount Антоновского /sf/ SQLite — для objective_etl task.
|
# Read-only bind-mount Антоновского /sf/ SQLite — для objective_etl task.
|
||||||
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
|
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
|
||||||
# контейнера задан settings.objective_anton_sqlite_path (default
|
# контейнера задан settings.objective_anton_sqlite_path (default
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,92 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/parcels/{cad_num}/report": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Собрать полный PDF-отчёт участка (§1–§7 + карты) — enqueue (#2259 PR-D)
|
||||||
|
* @description Поставить в очередь сборку полного PDF-отчёта участка (или вернуть готовый кэш).
|
||||||
|
*
|
||||||
|
* Готовый кэш (метадата-ран `report-pdf-1.0` для последних analyze+forecast ранов +
|
||||||
|
* файл на диске) → 200 {status:"ready"} (качать сразу через /report/download). Иначе
|
||||||
|
* enqueue фоновой Celery-таски `build_full_report_task(cad)` → 202 {status:"building"}.
|
||||||
|
* Best-effort enqueue: провал Celery/Redis НЕ роняет ответ (503 честнее 500, но и он
|
||||||
|
* крайне редок — /analyze уже enqueue'ит форсайт тем же брокером).
|
||||||
|
*
|
||||||
|
* В ответе — даты базовых ранов (по каким данным собирается/собран отчёт).
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* cad_num: кадастровый номер участка.
|
||||||
|
*/
|
||||||
|
post: operations["build_parcel_report_api_v1_parcels__cad_num__report_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/parcels/{cad_num}/report/status": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Статус полного PDF-отчёта участка (#2259 PR-D)
|
||||||
|
* @description Состояние полного PDF-отчёта: ready / building / none.
|
||||||
|
*
|
||||||
|
* ready — метадата-ран `report-pdf-1.0` есть И файл на диске (качать через
|
||||||
|
* /report/download); building — analyze-ран есть, но готового PDF нет (собирается или
|
||||||
|
* ещё не запускался POST /report); none — нет даже analyze-рана (отчётировать нечего).
|
||||||
|
* Read-only поллинг: сбой чтения → building (не 500), клиент повторит.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* cad_num: кадастровый номер участка.
|
||||||
|
*/
|
||||||
|
get: operations["parcel_report_status_api_v1_parcels__cad_num__report_status_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/parcels/{cad_num}/report/download": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Скачать готовый полный PDF-отчёт участка (#2259 PR-D)
|
||||||
|
* @description Отдать готовый PDF-файл полного отчёта (application/pdf).
|
||||||
|
*
|
||||||
|
* Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с
|
||||||
|
* Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
|
||||||
|
* файл не найден → 404 (сначала POST /report, дождаться status=ready).
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* cad_num: кадастровый номер участка.
|
||||||
|
*/
|
||||||
|
get: operations["download_parcel_report_api_v1_parcels__cad_num__report_download_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/parcels/{cad_num}/analyze": {
|
"/api/v1/parcels/{cad_num}/analyze": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -5146,6 +5232,46 @@ export interface components {
|
||||||
/** Developers */
|
/** Developers */
|
||||||
developers?: string[] | null;
|
developers?: string[] | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ReportBuildResponse
|
||||||
|
* @description Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).
|
||||||
|
*
|
||||||
|
* status="building" (202) — таска поставлена в очередь, PDF собирается в фоне;
|
||||||
|
* status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске,
|
||||||
|
* качать сразу через /report/download.
|
||||||
|
*/
|
||||||
|
ReportBuildResponse: {
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: "building" | "ready";
|
||||||
|
/** Analyze Run At */
|
||||||
|
analyze_run_at?: string | null;
|
||||||
|
/** Forecast Run At */
|
||||||
|
forecast_run_at?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ReportStatusResponse
|
||||||
|
* @description Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).
|
||||||
|
*
|
||||||
|
* status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
|
||||||
|
* "building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не
|
||||||
|
* запускался), "none" (нет даже analyze-рана — отчётировать нечего).
|
||||||
|
*/
|
||||||
|
ReportStatusResponse: {
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: "ready" | "building" | "none";
|
||||||
|
/** Analyze Run At */
|
||||||
|
analyze_run_at?: string | null;
|
||||||
|
/** Forecast Run At */
|
||||||
|
forecast_run_at?: string | null;
|
||||||
|
/** Report Generated At */
|
||||||
|
report_generated_at?: string | null;
|
||||||
|
};
|
||||||
/** RevokeRequest */
|
/** RevokeRequest */
|
||||||
RevokeRequest: {
|
RevokeRequest: {
|
||||||
/** Task Id */
|
/** Task Id */
|
||||||
|
|
@ -5928,6 +6054,99 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
build_parcel_report_api_v1_parcels__cad_num__report_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
cad_num: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReportBuildResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parcel_report_status_api_v1_parcels__cad_num__report_status_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
cad_num: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReportStatusResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
download_parcel_report_api_v1_parcels__cad_num__report_download_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
cad_num: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
analyze_parcel_api_v1_parcels__cad_num__analyze_post: {
|
analyze_parcel_api_v1_parcels__cad_num__analyze_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue