From 880bac77380fed985e1c3d5958a5d76551839be1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 15:56:58 +0500 Subject: [PATCH] =?UTF-8?q?feat(reports):=20Celery-=D0=B4=D0=B6=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B3=D0=BE=20PDF?= =?UTF-8?q?-=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20+=20=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=20+=203=20endpoint'=D0=B0=20(#2259=20PR-D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оркестратор build_full_report собирает §1-§7 + карты из analyze/forecast ранов (реальная schema форсайта = "1.0"), best-effort дотягивает connection-capacity (§3-резервы + сети рядом) и server-side концепцию (поток POST /concepts: ConceptInput comfort → generate), рендерит WeasyPrint'ом (локальный import — web-процесс native не тянет) на volume /app/reports с метадата-раном report-pdf-1.0. Кэш по (analyze_run_id, forecast_run_id) с проверкой существования файла (сирота → ре-рендер). Запись атомарная (tmp+os.replace), имя файла из whitelist-санитизации кад.номера. Celery task on-demand (time_limit 600), endpoint'ы POST /{cad}/report (202/200) + status + download (дата из generated_at). Deploy: bind-mount ./reports:/app/reports (backend+worker) + mkdir/chown uid1000 в deploy.yml (docker создал бы root:root → PermissionError → вечный building). reports/ в .gitignore. api-types регенерён (+219). Refs #2259 --- .forgejo/workflows/deploy.yml | 8 + .gitignore | 4 + backend/app/api/v1/parcels.py | 181 ++++++++++ backend/app/core/config.py | 7 + backend/app/schemas/parcel.py | 33 ++ .../services/exporters/full_report_html.py | 134 +++++++- .../app/services/exporters/full_report_pdf.py | 325 ++++++++++++++++++ backend/app/workers/celery_app.py | 1 + backend/app/workers/tasks/full_report.py | 80 +++++ backend/tests/api/v1/test_parcel_report.py | 275 +++++++++++++++ .../exporters/test_full_report_html_part_a.py | 62 ++++ .../exporters/test_full_report_pdf.py | 267 ++++++++++++++ docker-compose.prod.yml | 6 + frontend/src/lib/api-types.ts | 219 ++++++++++++ 14 files changed, 1594 insertions(+), 8 deletions(-) create mode 100644 backend/app/services/exporters/full_report_pdf.py create mode 100644 backend/app/workers/tasks/full_report.py create mode 100644 backend/tests/api/v1/test_parcel_report.py create mode 100644 backend/tests/services/exporters/test_full_report_pdf.py diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index be9b0ffb..9ed960aa 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index c54a5b53..ef5a392e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 01179732..18eb1f27 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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__.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, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0bd88705..420c1dbc 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 347a8fbc..9f48e48b 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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) ─────────────────────────────── diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index 96a2a154..05e715fa 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -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("

Электроснабжение — свободная мощность

" + _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( + "

Водоснабжение/водоотведение — резервы

" + + _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( + "

Газоснабжение — свободная мощность ГРС

" + + _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( + "

Теплоснабжение — резервы систем

" + + _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( + "

Сети рядом (охранные зоны)

" + + _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"""
@@ -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)}
{_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 (три `
` + опциональный блок альтернатив), готовый как `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 diff --git a/backend/app/services/exporters/full_report_pdf.py b/backend/app/services/exporters/full_report_pdf.py new file mode 100644 index 00000000..b7502d97 --- /dev/null +++ b/backend/app/services/exporters/full_report_pdf.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index e09657b3..8478d154 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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", diff --git a/backend/app/workers/tasks/full_report.py b/backend/app/workers/tasks/full_report.py new file mode 100644 index 00000000..86797f0c --- /dev/null +++ b/backend/app/workers/tasks/full_report.py @@ -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() diff --git a/backend/tests/api/v1/test_parcel_report.py b/backend/tests/api/v1/test_parcel_report.py new file mode 100644 index 00000000..61598f4a --- /dev/null +++ b/backend/tests/api/v1/test_parcel_report.py @@ -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() diff --git a/backend/tests/services/exporters/test_full_report_html_part_a.py b/backend/tests/services/exporters/test_full_report_html_part_a.py index d56575de..c4009996 100644 --- a/backend/tests/services/exporters/test_full_report_html_part_a.py +++ b/backend/tests/services/exporters/test_full_report_html_part_a.py @@ -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 ──────────────── diff --git a/backend/tests/services/exporters/test_full_report_pdf.py b/backend/tests/services/exporters/test_full_report_pdf.py new file mode 100644 index 00000000..d3a56050 --- /dev/null +++ b/backend/tests/services/exporters/test_full_report_pdf.py @@ -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" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6d7a8cfc..768c2af9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 3f3d4188..a324e6e5 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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__.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?: { -- 2.45.3