From bdd95063b661e5277de492cab93f257241d3dce7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:37:13 +0500 Subject: [PATCH] =?UTF-8?q?feat(ptica/reports):=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20DOCX-=D1=81=D0=BA=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20(#2259=20?= =?UTF-8?q?R2=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный отчёт отдаёт и PDF, и DOCX одним билдом (backend #2301) — карточка DOCX была honest-disabled «скоро», теперь неправда. useFullReport принимает format через pendingFormatRef — один общий прогон enqueue→poll, формат только у финального download, без дубля стейта; кто кликнул — тот формат и качается, обе карточки дизейблятся на общий building. DOCX-карточка ПТИЦА + вторичная DOCX-кнопка на светлой странице. Легаси-раны без docx_path: HEAD-preflight (same-origin, наследует basic_auth) ловит любой не-OK и просит пересобрать отчёт вместо молчаливой закачки страницы ошибки; сетевой сбой preflight — фолбэк на прямую навигацию. PPTX остаётся «скоро» честно (бэкенда нет). --- .../site-finder/analysis/ExportButtons.tsx | 52 +++++- .../ptica/reports/PticaReports.tsx | 73 +++++++- frontend/src/hooks/useFullReport.ts | 171 ++++++++++++++---- 3 files changed, 257 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx index 72821253..fd1fcb6a 100644 --- a/frontend/src/components/site-finder/analysis/ExportButtons.tsx +++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx @@ -9,13 +9,14 @@ */ import { useState } from "react"; -import { Download, FileText, FileBarChart } from "lucide-react"; +import { Download, FileText, FileBarChart, FileType } from "lucide-react"; import { API_BASE_URL } from "@/lib/api"; import { triggerDownload } from "@/lib/download"; import { useFullReport, formatElapsed, readyReportLabel, + readyDocxLabel, } from "@/hooks/useFullReport"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; @@ -238,6 +239,46 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) { ? readyReportLabel(fullReport.reportGeneratedAt) : "Полный отчёт (PDF)"} + + {/* + Full-report DOCX — вторичный компакт рядом с «Полный отчёт (PDF)». + Тот же общий прогон useFullReport (один enqueue→poll даёт pdf+docx), + финальный download в ?format=docx. Компактнее PDF-кнопки (иконка + + «DOCX»), чтобы не перегружать ряд. Building/ready-состояние — общее с + PDF-кнопкой (обе дизейблятся на сборке). + */} + {/* Full-report timeout / enqueue-error note (contract has no "failed") */} @@ -257,6 +298,15 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) { Не удалось поставить сборку отчёта — повторите

)} + {/* Легаси-ран без docx (собран до PR-F) → download?format=docx = 404. */} + {fullReport.downloadError && ( +

+ {fullReport.downloadError} +

+ )} {/* Error message */} {error && ( diff --git a/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx b/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx index fd64fd41..2f2947d1 100644 --- a/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx +++ b/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx @@ -14,10 +14,17 @@ * being ready (useParcelForecastQuery): until then the cards are disabled * with an honest «готовится» tag. * - * Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards) - * have NO backend endpoint — the only Word/PowerPoint export the backend ships - * is the §22 forecast above. So those two cards render DISABLED with a «скоро» - * tag (honest — we never trigger a download that 404s). + * Full-report DOCX (#2259 R2 PR-F) — REAL: GET /report/download?format=docx. + * One report build produces BOTH pdf + docx, so the DOCX card shares the SAME + * useFullReport(cad) run as the full-report PDF card (enqueue → poll → download) + * — clicking either kicks the same job; only the final download format differs + * (remembered by the hook via pendingFormatRef). Legacy runs (built before PR-F) + * carry no docx_path → the hook HEAD-preflights the docx URL and, on 404, shows + * «Пересоберите отчёт» instead of silently downloading an HTML error page. + * + * Full-report PPTX (the prototype's presentation card) has NO backend endpoint — + * the only PowerPoint export the backend ships is the §22 forecast above. So that + * card renders DISABLED with a «скоро» tag (honest — we never trigger a 404). * * The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections * (matches the prototype TOC); the PDF snapshot is the real multi-section @@ -34,6 +41,7 @@ import { useFullReport, formatElapsed, readyReportLabel, + readyDocxLabel, } from "@/hooks/useFullReport"; import type { ParcelAnalysis } from "@/types/site-finder"; import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; @@ -364,6 +372,55 @@ export function PticaReports({ cad, analysis }: Props) { )} + {/* Full-report DOCX — REAL (#2259 R2 PR-F): same run as PDF card, + final download ?format=docx. Building-спиннер общий на прогон. */} + + {/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
)} + {/* Легаси-раны без docx (собраны до PR-F) → download?format=docx = 404. + Хук ловит это HEAD-preflight'ом и просит пересобрать отчёт. */} + {fullReport.downloadError && ( +

+ {fullReport.downloadError} +

+ )} + {/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */}

diff --git a/frontend/src/hooks/useFullReport.ts b/frontend/src/hooks/useFullReport.ts index 282a556c..bfa0cce7 100644 --- a/frontend/src/hooks/useFullReport.ts +++ b/frontend/src/hooks/useFullReport.ts @@ -24,7 +24,20 @@ export type ReportStatusResponse = * → 200 { status: "ready", ... } готовый кэш уже на диске, качать сразу * • GET /api/v1/parcels/{cad}/report/status * → { status: "none" | "building" | "ready", report_generated_at?, ... } - * • GET /api/v1/parcels/{cad}/report/download → application/pdf (attachment) + * • GET /api/v1/parcels/{cad}/report/download?format=pdf|docx + * → application/pdf | application/vnd.openxmlformats-…docx (attachment) + * + * Один report-билд даёт ОБА файла (PDF + DOCX) — status=ready означает, что оба + * готовы для свежих ранов (#2259 R2 PR-F). Формат влияет только на финальный + * download: и PDF-, и DOCX-карточка гоняют ОДИН общий прогон enqueue→poll, + * а какой файл скачать в конце — запоминает pendingFormatRef (кто кликнул — тот + * формат и качается). Отдельного state под формат НЕТ. + * + * Легаси-раны (собранные до PR-F) не несут docx_path → download?format=docx даёт + * 404. Чтобы не «молча» скачивал HTML-страницу ошибки, DOCX-скачивание + * идёт через HEAD-preflight (triggerReportDownload с preflight): 404 → onDownloadError + * («Пересоберите отчёт»), ok → навигация . PDF-путь preflight не нужен (файл всегда + * есть при ready), остаётся прямой -навигацией. * * «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS, * фронт сам останавливает поллинг и показывает «Сборка затянулась — попробуйте @@ -40,6 +53,9 @@ export type ReportStatusResponse = const POLL_INTERVAL_MS = 5_000; const POLL_CAP_MS = 3 * 60 * 1000; +/** Формат финального download'а. Один прогон enqueue→poll — формат только у скачивания. */ +export type ReportFormat = "pdf" | "docx"; + export type FullReportUiState = | "idle" // ничего не запускалось / ещё не готов | "building" // джоба идёт, поллим status @@ -53,25 +69,40 @@ export interface UseFullReportResult { reportGeneratedAt: string | null; /** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */ elapsedMs: number; - /** Клик по кнопке: enqueue → сразу download если ready, иначе старт поллинга. */ - start: () => void; - /** Ручное скачивание готового PDF (кнопка «Скачать отчёт»). */ - download: () => void; + /** + * Клик по кнопке: enqueue → сразу download в `format` если ready, иначе старт + * поллинга (формат запоминается — финальный авто-download будет в нём же). + * format по умолчанию "pdf" — обратная совместимость со старыми вызовами. + */ + start: (format?: ReportFormat) => void; + /** Ручное скачивание готового отчёта в `format` (кнопка «Скачать …»). */ + download: (format?: ReportFormat) => void; + /** + * Легаси-раны без docx: download?format=docx вернул 404 → сообщение + * «Пересоберите отчёт». null пока ошибки нет. Сбрасывается на новом start(). + */ + downloadError: string | null; } function buildReportPath(cad: string, suffix: string): string { return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`; } +/** URL download'а с ?format= (pdf — дефолт бэкенда, не гоним лишний query). */ +function downloadUrl(cad: string, format: ReportFormat): string { + const base = `${API_BASE_URL}${buildReportPath(cad, "/download")}`; + return format === "docx" ? `${base}?format=docx` : base; +} + /** * Навигация браузера к /report/download через скрытый . Наследует * Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не * нужен. Browser-only (использует document) — вызывать только из обработчика. */ -function triggerReportDownload(cad: string): void { +function triggerAnchorDownload(cad: string, format: ReportFormat): void { if (typeof document === "undefined") return; const a = document.createElement("a"); - a.href = `${API_BASE_URL}${buildReportPath(cad, "/download")}`; + a.href = downloadUrl(cad, format); // Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут // без значения лишь подтверждает намерение скачать, не переопределяя имя. a.download = ""; @@ -81,16 +112,59 @@ function triggerReportDownload(cad: string): void { document.body.removeChild(a); } +/** + * Скачать отчёт в `format`. PDF-путь — прямая -навигация (файл всегда есть при + * ready). DOCX-путь — HEAD-preflight: легаси-раны без docx_path отдают 404, а + * на 404 «молча» скачал бы HTML-страницу ошибки; ловим здесь и зовём + * onError («Пересоберите отчёт»). HEAD дешевле GET (тело не тянется) и наследует + * ту же basic_auth сессию, что и . Сеть упала — не блокируем: пробуем . + */ +async function triggerReportDownload( + cad: string, + format: ReportFormat, + onError: () => void, +): Promise { + if (format === "pdf") { + triggerAnchorDownload(cad, "pdf"); + return; + } + try { + const res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" }); + // Любой не-OK (404 легаси-рана, 5xx) — не даём молча скачать страницу ошибки. + if (!res.ok) { + onError(); + return; + } + } catch { + // Сетевая ошибка preflight'а (offline / CORS) — не проглатываем download: + // отдаём -навигации, пусть браузер сам разрулит. + } + triggerAnchorDownload(cad, "docx"); +} + export function useFullReport(cad: string): UseFullReportResult { // Флаг активного поллинга — заводится на building, гасится на ready/timeout. const [polling, setPolling] = useState(false); const [timedOut, setTimedOut] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); + // Легаси-404 при download?format=docx — сообщение «Пересоберите отчёт». + const [downloadError, setDownloadError] = useState(null); // Момент начала сборки — источник истины для elapsed и cap. const startedAtRef = useRef(null); // На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы // загрузку снова. const autoDownloadedRef = useRef(false); + // Формат финального авто-download'а — «кто кликнул, тот формат и качается». + // Ref (не state): читается только внутри обработчиков/эффекта, ре-рендер под + // него не нужен, и это держит один общий прогон без дубля состояния формата. + const pendingFormatRef = useRef("pdf"); + + // Стабильный колбэк ошибки docx-download'а для triggerReportDownload. + const handleDownloadError = useCallback(() => { + setDownloadError( + "Пересоберите отчёт — DOCX для старого прогона отсутствует", + ); + }, []); // ── POST enqueue ──────────────────────────────────────────────────────────── const enqueue = useMutation({ @@ -142,16 +216,20 @@ export function useFullReport(cad: string): UseFullReportResult { return () => window.clearInterval(id); }, [polling]); - // ── На ready: авто-download один раз + стоп поллинга ────────────────────────── + // ── На ready: авто-download один раз (в запомненном формате) + стоп поллинга ─── useEffect(() => { if (statusData?.status === "ready" && polling) { setPolling(false); if (!autoDownloadedRef.current) { autoDownloadedRef.current = true; - triggerReportDownload(cad); + void triggerReportDownload( + cad, + pendingFormatRef.current, + handleDownloadError, + ); } } - }, [statusData?.status, polling, cad]); + }, [statusData?.status, polling, cad, handleDownloadError]); // ── Смена участка: полный сброс прогона ────────────────────────────────────── // Оба потребителя живут под route-сегментом [cad] (смена участка = remount), @@ -160,36 +238,48 @@ export function useFullReport(cad: string): UseFullReportResult { setPolling(false); setTimedOut(false); setElapsedMs(0); + setDownloadError(null); autoDownloadedRef.current = false; startedAtRef.current = null; + pendingFormatRef.current = "pdf"; }, [cad]); // ── Действие: клик по кнопке ────────────────────────────────────────────────── - const start = useCallback(() => { - // Сброс прошлого прогона (retry после timeout/error). - setTimedOut(false); - setElapsedMs(0); - startedAtRef.current = null; - autoDownloadedRef.current = false; + const start = useCallback( + (format: ReportFormat = "pdf") => { + // Сброс прошлого прогона (retry после timeout/error) + запомнить формат + // финального download'а: кто кликнул PDF/DOCX — тот файл и скачается. + pendingFormatRef.current = format; + setTimedOut(false); + setElapsedMs(0); + setDownloadError(null); + startedAtRef.current = null; + autoDownloadedRef.current = false; - enqueue.mutate(undefined, { - onSuccess: (data) => { - if (data.status === "ready") { - // Кэш готов — качаем сразу, поллинг не нужен. - autoDownloadedRef.current = true; - triggerReportDownload(cad); - } else { - // building → стартуем поллинг status. - startedAtRef.current = Date.now(); - setPolling(true); - } - }, - }); - }, [enqueue, cad]); + enqueue.mutate(undefined, { + onSuccess: (data) => { + if (data.status === "ready") { + // Кэш готов — качаем сразу, поллинг не нужен. + autoDownloadedRef.current = true; + void triggerReportDownload(cad, format, handleDownloadError); + } else { + // building → стартуем поллинг status. + startedAtRef.current = Date.now(); + setPolling(true); + } + }, + }); + }, + [enqueue, cad, handleDownloadError], + ); - const download = useCallback(() => { - triggerReportDownload(cad); - }, [cad]); + const download = useCallback( + (format: ReportFormat = "pdf") => { + setDownloadError(null); + void triggerReportDownload(cad, format, handleDownloadError); + }, + [cad, handleDownloadError], + ); // ── Производное UI-состояние ────────────────────────────────────────────────── let uiState: FullReportUiState = "idle"; @@ -206,7 +296,14 @@ export function useFullReport(cad: string): UseFullReportResult { const reportGeneratedAt = (uiState === "ready" ? statusData?.report_generated_at : null) ?? null; - return { uiState, reportGeneratedAt, elapsedMs, start, download }; + return { + uiState, + reportGeneratedAt, + elapsedMs, + start, + download, + downloadError, + }; } // ── Хелперы микрокопии кнопки (общие для обоих компонентов) ──────────────────── @@ -225,6 +322,12 @@ export function readyReportLabel(iso: string | null): string { return d ? `Скачать отчёт (${d})` : "Скачать отчёт"; } +/** «Скачать DOCX (дата)» — параллель readyReportLabel для DOCX-карточки. */ +export function readyDocxLabel(iso: string | null): string { + const d = formatReportDate(iso); + return d ? `Скачать DOCX (${d})` : "Скачать DOCX"; +} + /** ISO → «ru-RU» дата генерации отчёта (репо-конвенция, NaN-fallback). */ export function formatReportDate(iso: string | null): string { if (!iso) return "";