feat(reports): Celery-джоба полного PDF-отчёта + кэш + 3 endpoint'а (#2259 PR-D) #2287

Merged
bot-backend merged 1 commit from feat/full-report-integration into main 2026-07-03 11:13:07 +00:00
14 changed files with 1594 additions and 8 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

@ -295,6 +295,20 @@ def _fmt_pct(fraction: Any) -> str:
return value + "%" return value + "%"
def _fmt_pct_raw(value: Any) -> str:
"""Процент, УЖЕ выраженный в единицах 0100 (напр. 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

View 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

View file

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

View 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()

View 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()

View file

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

View 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"

View file

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

View file

@ -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?: {