feat(exporters): DOCX-вариант полного отчёта ПТИЦА (#2259 R2) (#2301)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 3m34s
Deploy / build-worker (push) Successful in 4m25s
Deploy / build-frontend (push) Successful in 5m25s
Deploy / deploy (push) Successful in 1m33s

This commit is contained in:
bot-backend 2026-07-03 20:21:19 +00:00
parent 3945060f0c
commit c129515ab8
6 changed files with 1866 additions and 27 deletions

View file

@ -1695,31 +1695,61 @@ def parcel_report_status(
)
# Медиа-типы + расширения по формату скачивания полного отчёта (PR-D pdf + PR-F docx).
_REPORT_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@router.get(
"/{cad_num}/report/download",
summary="Скачать готовый полный PDF-отчёт участка (#2259 PR-D)",
summary="Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F)",
)
def download_parcel_report(
cad_num: str,
db: Annotated[Session, Depends(get_db)],
format: Annotated[
Literal["pdf", "docx"],
Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"),
] = "pdf",
) -> FileResponse:
"""Отдать готовый PDF-файл полного отчёта (application/pdf).
"""Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).
Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) FileResponse с
Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
файл не найден 404 (сначала POST /report, дождаться status=ready).
`format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате
честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
ключ кэша не изменился, но файла docx нет пере-рендер запишет оба).
Args:
cad_num: кадастровый номер участка.
format: "pdf" (default) | "docx".
"""
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"]
if format == "docx":
file_path = cached.get("docx_path")
media_type = _REPORT_DOCX_MEDIA_TYPE
ext = "docx"
# Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report.
if not isinstance(file_path, str) or not Path(file_path).exists():
raise HTTPException(
status_code=404,
detail=(
"DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)"
),
)
else:
file_path = cached["pdf_path"]
media_type = "application/pdf"
ext = "pdf"
cad_safe = cad_num.replace(":", "_")
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда PDF реально собран), НЕ today: на
# Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на
# cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из
# build_full_report; битую/отсутствующую парсим best-effort → fallback на today.
date_str = _dt.date.today().strftime("%Y-%m-%d")
@ -1729,10 +1759,10 @@ def download_parcel_report(
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"
filename = f"gendesign_report_{cad_safe}_{date_str}.{ext}"
return FileResponse(
pdf_path,
media_type="application/pdf",
file_path,
media_type=media_type,
filename=filename,
)

File diff suppressed because it is too large Load diff

View file

@ -17,8 +17,11 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc
отчёт без §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).
6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ
карт-PNG (НЕ рендерим карты дважды) рядом `.docx`-файл.
7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0`
(result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/
docx_size_bytes). Старые раны без docx_path download?format=docx отдаёт 404.
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
@ -41,6 +44,7 @@ from app.services.analysis_runs.repository import (
latest_run_for,
persist_analysis_run,
)
from app.services.exporters.full_report_docx import build_full_report_docx
from app.services.exporters.full_report_html import (
MAP_CONCEPT_PLACEHOLDER,
MAP_PARCEL_PLACEHOLDER,
@ -243,6 +247,19 @@ def _cad_safe(cad: str) -> str:
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
def _atomic_write(path: Path, data: bytes) -> None:
"""Атомарно записать байты в `path`: `.<pid>.tmp` рядом → `os.replace`.
Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату)
прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл
(битый вывод). `os.replace` атомарен в пределах одной FS download всегда видит
целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём).
"""
tmp_path = path.with_suffix(f".{os.getpid()}.tmp")
tmp_path.write_bytes(data)
os.replace(tmp_path, path)
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
@ -323,30 +340,44 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
pdf_bytes = render_full_report_pdf(html)
# Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok).
# DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
# concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1§7 PDF.
docx_bytes = build_full_report_docx(
analyze,
forecast,
concept,
connection_capacity,
cad=cad,
address=address if isinstance(address, str) else None,
generated_at=generated_at_ru,
parcel_map_png=parcel_png,
concept_map_png=concept_png,
)
# Запись файлов на 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)
base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
pdf_path = reports_dir / f"{base_name}.pdf"
docx_path = reports_dir / f"{base_name}.docx"
# Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
_atomic_write(pdf_path, pdf_bytes)
_atomic_write(docx_path, docx_bytes)
size_bytes = len(pdf_bytes)
docx_size_bytes = len(docx_bytes)
result: dict[str, Any] = {
"pdf_path": str(pdf_path),
"docx_path": str(docx_path),
"analyze_run_id": analyze_run_id,
"forecast_run_id": forecast_run_id,
"generated_at": generated_at.isoformat(),
"size_bytes": size_bytes,
"docx_size_bytes": docx_size_bytes,
}
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF
# уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит).
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
# уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит).
persist_analysis_run(
db,
cad_num=cad,
@ -359,10 +390,12 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
created_by=None,
)
logger.info(
"build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s",
"build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
cad,
pdf_path,
size_bytes,
docx_path,
docx_size_bytes,
analyze_run_id,
forecast_run_id,
)

View file

@ -273,3 +273,152 @@ def test_download_404_when_not_ready() -> None:
assert "готов" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
# ── GET /{cad}/report/download?format=docx (#2259 PR-F) ─────────────────────────
def test_download_docx_returns_word_when_ready(tmp_path: Path) -> None:
"""format=docx + docx_path на диске → 200 + Word media-type + .docx filename."""
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-1.7 payload")
docx = tmp_path / "report.docx"
docx.write_bytes(b"PK\x03\x04 fake-docx")
report_row = _run(
500,
{
"pdf_path": str(pdf),
"docx_path": str(docx),
"analyze_run_id": 101,
"forecast_run_id": 202,
"generated_at": "2026-07-01T09:00:00+00:00",
"size_bytes": 16,
"docx_size_bytes": 12,
},
)
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?format=docx")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.docx"')
assert "2026-07-01" in cd
assert resp.content == b"PK\x03\x04 fake-docx"
finally:
app.dependency_overrides.clear()
def test_download_default_format_is_pdf(tmp_path: Path) -> None:
"""format не задан → default pdf (даже если docx_path тоже есть)."""
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-1.7 payload")
docx = tmp_path / "report.docx"
docx.write_bytes(b"PK\x03\x04 fake-docx")
report_row = _run(
500,
{
"pdf_path": str(pdf),
"docx_path": str(docx),
"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"
assert resp.content == b"%PDF-1.7 payload"
finally:
app.dependency_overrides.clear()
def test_download_docx_404_when_legacy_run_has_no_docx(tmp_path: Path) -> None:
"""Старый ран (собран до PR-F) без docx_path → format=docx 404 «пересоберите»."""
from app.core.db import get_db
pdf = tmp_path / "report.pdf"
pdf.write_bytes(b"%PDF-1.7 payload")
# Метадата без docx_path — legacy отчёт, собранный до PR-F.
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?format=docx")
assert resp.status_code == 404, resp.text
assert "пересоберите" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_download_docx_404_when_file_missing(tmp_path: Path) -> None:
"""docx_path в метадате есть, но файл на диске исчез → format=docx 404."""
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),
"docx_path": str(tmp_path / "gone.docx"), # файла нет
"analyze_run_id": 101,
"forecast_run_id": 202,
"generated_at": "2026-07-01T09:00:00+00:00",
"size_bytes": 4,
},
)
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?format=docx")
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_download_invalid_format_returns_422() -> None:
"""Неизвестный format (не pdf/docx) → 422 (Literal-валидация FastAPI)."""
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=xlsx")
assert resp.status_code == 422, resp.text
finally:
app.dependency_overrides.clear()

View file

@ -0,0 +1,404 @@
"""Unit-тесты DOCX-варианта полного отчёта ПТИЦА (эпик #2259 PR-F) — `build_full_report_docx`.
Чистые тесты БЕЗ БД / сети / native-libs (python-docx чистый Python на lxml): билдер
только ПОТРЕБЛЯЕТ уже-собранные словари (analyze / forecast / concept / connection-
capacity) + карт-PNG. Проверяем:
полный payload НЕПУСТЫЕ байты, начинаются с `b"PK"` (OOXML/.docx zip);
документ открывается python-docx и несёт заголовки §1§7 + альтернативы + титул;
деградации: нет forecast §4§6 «нет данных»; нет концепции §7 плашка + §5 контекст;
нет connection-capacity §3 без резервов; пустой payload валидный минимальный .docx;
карты вставлены (inline_shapes) при наличии PNG; None плашка «Карта недоступна».
DATABASE_URL до импорта app-модулей (зеркало test_full_report_pdf.py) на случай
side-effect'ов импорта пакета.
"""
from __future__ import annotations
import io
import os
import struct
import zlib
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from docx import Document
from app.services.exporters.full_report_docx import build_full_report_docx
_CAD = "66:41:0204016:10"
def _png_1x1() -> bytes:
"""Минимальный валидный 1×1 PNG (белый пиксель) для проверки вставки карт.
Собираем вручную (chunk = len + type + data + CRC32) не тянем PIL в фикстуру.
python-docx декодирует размеры прямо из IHDR, реальный растр ему не важен.
"""
def _chunk(tag: bytes, data: bytes) -> bytes:
body = tag + data
crc = struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
return struct.pack(">I", len(data)) + body + crc
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1×1, 8-bit, truecolor
idat = zlib.compress(b"\x00\xff\xff\xff") # one filtered scanline, white
return sig + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
@pytest.fixture
def png() -> bytes:
return _png_1x1()
def _analyze() -> dict[str, Any]:
return {
"cad_num": _CAD,
"geom_geojson": {"type": "Polygon", "coordinates": [[[60.6, 56.8], [60.61, 56.8]]]},
"egrn": {
"address": "ЕКБ, ул. Примерная, 1",
"area_m2": 7800,
"land_category": "Земли населённых пунктов",
},
"nspd_zoning": {
"zone_code": "Ж-4",
"max_far": 2.5,
"max_floors": 16,
"main_vri": ["МКД", "Детсад"],
},
"encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Приаэродромная"]},
"gate_verdict": {"can_build_mkd": True, "verdict_label": "Можно"},
"location": {"distance_to_center_km": 4.2},
"metro": {"nearest_top3": [{"name": "Пл.1905", "distance_m": 1200}]},
"utilities": {
"nearest_substation_m": 300,
"summary": [{"subtype": "power", "nearest_m": 300, "count_within_2km": 5}],
},
"neighbors_summary": {
"neighbors": [
{"building_name": "ЖК А", "purpose": "жилое", "floors": 12, "distance_m": 80}
]
},
"program_alternatives": {
"any_viable": True,
"grid_size": 9,
"variants": [
{
"housing_class": "comfort",
"floors": 16,
"development_type": "high_rise",
"npv_rub": 42_000_000,
"roi": 0.18,
"irr": 0.22,
"residential_sqm": 12_000,
"apartments_count": 240,
"price_per_sqm_used": 120_000,
"price_source": "objective_geo_radius",
}
],
"caveat": "Оценка по макс. застройке зоны.",
},
}
def _forecast() -> dict[str, Any]:
return {
"schema_version": "1.0",
"market_now": {
"summary": "Рынок активен.",
"market_metrics": {"district": "Кировский", "n_lots": 540, "unit_velocity": 12.3},
"competitors": [
{
"comm_name": "ЖК Б",
"dev_name": "Дев1",
"obj_class": "comfort",
"distance_m": 500,
"flat_count": 120,
}
],
},
"confidence": {
"level": "high",
"rationale": "много данных",
"factors": {"deals": {"label": "Сделки", "level": "high", "note": "2000 сделок"}},
},
"future_market": {
"summary": "Дефицит нарастает.",
"forecasts_by_horizon": [
{
"horizon_months": 12,
"projected_demand_units": 300,
"projected_supply_units": 200,
"deficit_index": 1.5,
"months_of_inventory": 8,
"confidence": "medium",
}
],
},
"scoring": {
"special_indices": {
"indices": {
"cost_of_error": {
"key": "coe",
"label": "Цена ошибки",
"value": 0.3,
"detail": {"price_per_m2": 125_000, "avg_ticket_rub": 7_500_000},
}
}
}
},
"scenarios": {"scenarios_summary": {"base": 1.4, "optimistic": 1.8}},
}
def _concept() -> dict[str, Any]:
return {
"variants": [
{
"strategy": "balanced",
"teap": {
"built_area_sqm": 2000,
"total_floor_area_sqm": 24_000,
"residential_area_sqm": 18_000,
"apartments_count": 300,
"parking_spaces": 150,
"density": 2.4,
},
"financial": {
"revenue_rub": 3_600_000_000,
"cost_rub": 2_800_000_000,
"net_profit_rub": 800_000_000,
"npv_rub": 420_000_000,
"roi": 0.28,
"irr": 0.19,
"price_per_sqm_used": 125_000,
"price_source": "objective_geo_radius",
"payback_months": 36,
"construction_rub": 2_000_000_000,
},
"buildings_geojson": {
"features": [
{"properties": {"section_type": "tower", "floors": 16}},
{"properties": {"section_type": "tower", "floors": 16}},
]
},
}
]
}
def _capacity() -> dict[str, Any]:
return {
"power_summary": {
"total_power_points": 3,
"nearest_with_reserve": {
"reserve_mva": 12.5,
"voltage_class": "10кВ",
"distance_m": 400,
"reserve_unit": "МВА",
},
},
"gas": {
"city_grs": [
{"grs_name": "ГРС-1", "free_capacity_th_m3_h": 50, "free_capacity_pct": 18.4}
]
},
"heat": {
"systems": [
{
"org": "ЕКБ ТЭЦ",
"system_name": "Система 1",
"reserve_gcal_h": 120,
"period": "2024",
}
]
},
"water": [{"system_name": "ЦСВ ЕКБ", "reserve_thousand_m3_day": 30, "period": "2024"}],
"nearby_network_zones": [{"human_label": "ЛЭП 10кВ", "distance_m": 50}],
}
def _all_text(doc: Document) -> str:
"""Весь текст документа: абзацы + ячейки всех таблиц (для проверки содержимого)."""
parts = [p.text for p in doc.paragraphs]
for table in doc.tables:
for row in table.rows:
parts.extend(cell.text for cell in row.cells)
return "\n".join(parts)
def _render(**overrides: Any) -> bytes:
kwargs: dict[str, Any] = {
"analyze_result": _analyze(),
"forecast_result": _forecast(),
"concept_result": _concept(),
"connection_capacity": _capacity(),
"cad": _CAD,
"address": "ЕКБ, ул. Примерная, 1",
"generated_at": "03.07.2026",
"parcel_map_png": None,
"concept_map_png": None,
}
kwargs.update(overrides)
analyze = kwargs.pop("analyze_result")
forecast = kwargs.pop("forecast_result")
concept = kwargs.pop("concept_result")
cap = kwargs.pop("connection_capacity")
return build_full_report_docx(analyze, forecast, concept, cap, **kwargs)
# ── Happy path ──────────────────────────────────────────────────────────────────
def test_full_payload_returns_docx_bytes(png: bytes) -> None:
"""Полный payload → непустые байты, начинающиеся с b"PK" (OOXML zip)."""
out = _render(parcel_map_png=png, concept_map_png=png)
assert isinstance(out, bytes)
assert len(out) > 0
assert out[:2] == b"PK" # zip-контейнер
def test_all_sections_present(png: bytes) -> None:
"""Документ несёт заголовки §1§7 + альтернативы + титул (зеркало PDF-структуры)."""
out = _render(parcel_map_png=png, concept_map_png=png)
doc = Document(io.BytesIO(out))
headings = {p.text for p in doc.paragraphs}
for section in (
"§1. Участок",
"§2. Окружение",
"§3. Инженерные сети",
"§4. Рынок",
"§5. Финансовая модель",
"§6. Риски и дефицит",
"§7. Концепция застройки",
):
assert section in headings, f"нет секции {section}"
assert "Как участок сходится (альтернативы программы)" in headings
assert "Отчёт по участку — Site Finder ПТИЦА" in headings
def test_title_carries_cad_and_address(png: bytes) -> None:
"""Титул несёт кад.номер, адрес и дату формирования."""
out = _render(parcel_map_png=png, concept_map_png=png)
text = _all_text(Document(io.BytesIO(out)))
assert _CAD in text
assert "ЕКБ, ул. Примерная, 1" in text
assert "03.07.2026" in text
def test_key_data_rendered(png: bytes) -> None:
"""Данные из исходных словарей попадают в таблицы (площадь / зона / NPV / метро)."""
out = _render(parcel_map_png=png, concept_map_png=png)
text = _all_text(Document(io.BytesIO(out)))
assert "7 800" in text # площадь через _fmt_int_ru
assert "Ж-4" in text # зона ПЗЗ
assert "Пл.1905" in text # станция метро
assert "ЖК Б" in text # конкурент рынка
# ── Карты ───────────────────────────────────────────────────────────────────────
def test_maps_inserted_when_png_present(png: bytes) -> None:
"""Обе карты (участок §1 + концепция §7) вставлены как inline-изображения."""
out = _render(parcel_map_png=png, concept_map_png=png)
doc = Document(io.BytesIO(out))
assert len(doc.inline_shapes) == 2
def test_map_placeholder_when_png_none() -> None:
"""Карты None → плашка «Карта недоступна» вместо изображения (graceful)."""
out = _render(parcel_map_png=None, concept_map_png=None)
doc = Document(io.BytesIO(out))
assert len(doc.inline_shapes) == 0
assert "Карта недоступна" in _all_text(doc)
def test_broken_png_falls_back_to_placeholder() -> None:
"""Битый PNG → плашка «недоступна», отчёт не падает (best-effort вставка)."""
out = _render(parcel_map_png=b"not-a-png", concept_map_png=b"also-broken")
doc = Document(io.BytesIO(out))
assert len(doc.inline_shapes) == 0
assert "Карта недоступна" in _all_text(doc)
# ── Деградации ──────────────────────────────────────────────────────────────────
def test_no_forecast_degrades_part_b() -> None:
"""Нет forecast → §4§6 «нет данных», документ валиден (не падает)."""
out = _render(forecast_result=None)
doc = Document(io.BytesIO(out))
headings = {p.text for p in doc.paragraphs}
assert "§4. Рынок" in headings
assert "§6. Риски и дефицит" in headings
assert "нет данных" in _all_text(doc)
def test_no_concept_shows_plashka_and_market_context() -> None:
"""Нет концепции → §7 честная плашка + §5 только рыночный контекст цены."""
out = _render(concept_result=None)
text = _all_text(Document(io.BytesIO(out)))
assert "Концепция застройки не рассчитана" in text
# §5 деградирует в рыночный контекст (cost_of_error detail в forecast).
assert "Рыночный контекст цены" in text
def test_no_connection_capacity_omits_reserves() -> None:
"""Нет connection-capacity → §3 без блоков резервов (только OSM-сети)."""
out = _render(connection_capacity=None)
text = _all_text(Document(io.BytesIO(out)))
# OSM-сети остаются, а connection-capacity-заголовки не появляются.
assert "Инженерные сети рядом" in text
assert "Электроснабжение — свободная мощность" not in text
assert "Газоснабжение — свободная мощность ГРС" not in text
def test_no_alternatives_when_field_absent() -> None:
"""program_alternatives отсутствует → блок альтернатив не рисуется вовсе."""
analyze = _analyze()
analyze.pop("program_alternatives")
out = _render(analyze_result=analyze)
headings = {p.text for p in Document(io.BytesIO(out)).paragraphs}
assert "Как участок сходится (альтернативы программы)" not in headings
def test_empty_payload_produces_valid_docx() -> None:
"""Пустой payload (всё {}) → валидный минимальный .docx БЕЗ падения (graceful)."""
out = build_full_report_docx(
{},
{},
{},
{},
cad="x",
address=None,
generated_at="03.07.2026",
parcel_map_png=None,
concept_map_png=None,
)
assert out[:2] == b"PK"
doc = Document(io.BytesIO(out))
headings = {p.text for p in doc.paragraphs}
# Все секции присутствуют даже на пустом payload.
assert "§1. Участок" in headings
assert "§7. Концепция застройки" in headings
assert "нет данных" in _all_text(doc)
def test_garbage_payload_does_not_crash() -> None:
"""Мусорный payload (не-dict) нормализуется к пустому → валидный .docx."""
out = build_full_report_docx(
"не словарь", # type: ignore[arg-type]
"тоже не словарь", # type: ignore[arg-type]
None,
None,
cad=_CAD,
address=None,
generated_at="03.07.2026",
parcel_map_png=None,
concept_map_png=None,
)
assert out[:2] == b"PK"

View file

@ -410,15 +410,20 @@ export interface paths {
cookie?: never;
};
/**
* Скачать готовый полный PDF-отчёт участка (#2259 PR-D)
* @description Отдать готовый PDF-файл полного отчёта (application/pdf).
* Скачать готовый полный отчёт участка PDF или DOCX (#2259 PR-D/PR-F)
* @description Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX).
*
* Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) FileResponse с
* Content-Disposition attachment (`gendesign_report_<cad>_<date>.pdf`). Отчёт не готов /
* Content-Disposition attachment (`gendesign_report_<cad>_<date>.<ext>`). Отчёт не готов /
* файл не найден 404 (сначала POST /report, дождаться status=ready).
*
* `format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате
* честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к.
* ключ кэша не изменился, но файла docx нет пере-рендер запишет оба).
*
* Args:
* cad_num: кадастровый номер участка.
* format: "pdf" (default) | "docx".
*/
get: operations["download_parcel_report_api_v1_parcels__cad_num__report_download_get"];
put?: never;
@ -6118,7 +6123,10 @@ export interface operations {
};
download_parcel_report_api_v1_parcels__cad_num__report_download_get: {
parameters: {
query?: never;
query?: {
/** @description Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F) */
format?: "pdf" | "docx";
};
header?: never;
path: {
cad_num: string;