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).
|
||||
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
|
||||
mkdir -p backend
|
||||
touch backend/.env.runtime
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -87,6 +87,10 @@ sf_anton_snapshot.db-*
|
|||
data/osrm/*
|
||||
!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
|
||||
debug.log
|
||||
.ds-sync/
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import json
|
|||
import logging
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from shapely import wkt as _shp_wkt
|
||||
from shapely.geometry import Polygon
|
||||
from sqlalchemy import text
|
||||
|
|
@ -35,6 +37,8 @@ from app.schemas.parcel import (
|
|||
ParcelSearchRequest,
|
||||
ParcelSearchResponse,
|
||||
RedLine,
|
||||
ReportBuildResponse,
|
||||
ReportStatusResponse,
|
||||
RiskZone,
|
||||
UtilityInfrastructureResponse,
|
||||
)
|
||||
|
|
@ -46,6 +50,7 @@ from app.services.analysis_runs.repository import (
|
|||
list_runs_for,
|
||||
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.report_docx import render_report_docx
|
||||
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)
|
||||
def analyze_parcel(
|
||||
cad_num: str,
|
||||
|
|
|
|||
|
|
@ -204,6 +204,13 @@ class Settings(BaseSettings):
|
|||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||
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_* таблицы).
|
||||
# На проде монтируется bind-mount-ом docker-compose:
|
||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||
|
|
|
|||
|
|
@ -255,6 +255,39 @@ class ConnectionCapacityResponse(BaseModel):
|
|||
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) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -295,6 +295,20 @@ def _fmt_pct(fraction: Any) -> str:
|
|||
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:
|
||||
"""RU-метка класса жилья, иначе значение как есть (str). PURE."""
|
||||
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:
|
||||
"""§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + альтернативы программы.
|
||||
def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
|
||||
"""Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом из connection-capacity (#2259 PR-D).
|
||||
|
||||
NB: «Ресурсные резервы» (ЦП Россетей / Водоканал) и `nearby_network_zones` в
|
||||
persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их подтянет
|
||||
PR-D. Здесь только то, что реально есть в `analyze_result`.
|
||||
connection-capacity НЕ персистится в analyze-payload (ленивый эндпоинт), поэтому его
|
||||
результат дотягивает оркестратор PR-D (`get_connection_capacity`) и передаёт сюда
|
||||
отдельным аргументом. Ключи — из `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"""
|
||||
<div class="section" id="{_ANCHOR_S3}">
|
||||
|
|
@ -750,6 +853,8 @@ def _build_section_3(result: dict[str, Any]) -> str:
|
|||
{_build_utilities(result)}
|
||||
|
||||
{_build_engineering_nearby(result)}
|
||||
|
||||
{_build_connection_capacity(connection_capacity)}
|
||||
</div>
|
||||
{_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 «Сети».
|
||||
|
||||
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 (все секции → «нет данных»).
|
||||
cad: кадастровый номер участка (для логов; в HTML не встраивается напрямую —
|
||||
он приходит в каркас через `build_full_report_html`).
|
||||
connection_capacity: результат `get_connection_capacity` (ЦП/вода/газ/тепло/сети
|
||||
рядом) — НЕ персистится в analyze-payload, дотягивается оркестратором PR-D
|
||||
и рендерится блоком «Ресурсные резервы» в §3. None → блок пропускается.
|
||||
|
||||
Returns:
|
||||
HTML-фрагмент Part A (три `<div class="section">` + опциональный блок альтернатив),
|
||||
готовый как `part_a_html` для `build_full_report_html`.
|
||||
"""
|
||||
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(
|
||||
"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,
|
||||
len(result),
|
||||
bool(_as_dict(result.get("program_alternatives")).get("variants")),
|
||||
bool(_as_dict(connection_capacity)),
|
||||
)
|
||||
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.location_refresh",
|
||||
"app.workers.tasks.forecast",
|
||||
"app.workers.tasks.full_report",
|
||||
"app.workers.tasks.ird_harvest",
|
||||
"app.workers.tasks.ekb_krt_sync",
|
||||
"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
|
||||
|
||||
|
||||
# ── 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 ────────────────
|
||||
|
||||
|
||||
|
|
|
|||
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 читают этот файл при старте (см.
|
||||
# app/core/auth.py). Read-only — мутация только через PR.
|
||||
- ./auth/roles.yaml:/app/auth/roles.yaml:ro
|
||||
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker ПИШЕТ файл сюда, backend его
|
||||
# ЧИТАЕТ для /report/download → общий writable bind-mount на обоих сервисах.
|
||||
- ./reports:/app/reports
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
|
|
@ -181,6 +184,9 @@ services:
|
|||
condition: service_healthy
|
||||
volumes:
|
||||
- ./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.
|
||||
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
|
||||
# контейнера задан settings.objective_anton_sqlite_path (default
|
||||
|
|
|
|||
|
|
@ -343,6 +343,92 @@ export interface paths {
|
|||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5146,6 +5232,46 @@ export interface components {
|
|||
/** Developers */
|
||||
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: {
|
||||
/** 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: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue