This commit is contained in:
parent
3945060f0c
commit
c129515ab8
6 changed files with 1866 additions and 27 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
1215
backend/app/services/exporters/full_report_docx.py
Normal file
1215
backend/app/services/exporters/full_report_docx.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
404
backend/tests/services/exporters/test_full_report_docx.py
Normal file
404
backend/tests/services/exporters/test_full_report_docx.py
Normal 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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue