From 1312dec0e669a53423cbc3833f1a9ca8fe502388 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 16:14:11 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=C2=AB=D0=9F=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82=20(PDF)=C2=BB=20=E2=80=94=20enqu?= =?UTF-8?q?eue=20=E2=86=92=20=D0=BF=D0=BE=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=E2=86=92=20download=20(#2259=20PR-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Общий хук useFullReport(cad): POST /report (200 ready → мгновенный download; 202 building → TanStack-поллинг status 5с с cap 3 мин → честный timeout «Сборка затянулась — попробуйте позже»), авто-download по ready (ref-guard, один раз), скачивание скрытым — наследует Caddy basic_auth без отдельного blob-fetch. Сброс прогона при смене cad (страховка in-place реюза), elapsed по wall-clock (иммунно к троттлингу фоновых вкладок). Кнопка в PticaReports (вместо honest-disabled «скоро») и ExportButtons — логика не дублируется. Статусы сужены типами из api-types (не руками). Fix: паттерн reports/ из PR-D в .gitignore заякорен в корень (/reports/) — неякоренный матчил и frontend .../ptica/reports/. Refs #2259 --- .gitignore | 2 +- .../site-finder/analysis/ExportButtons.tsx | 75 +++++- .../ptica/reports/PticaReports.tsx | 62 ++++- frontend/src/hooks/useFullReport.ts | 233 ++++++++++++++++++ 4 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 frontend/src/hooks/useFullReport.ts diff --git a/.gitignore b/.gitignore index ef5a392e..53ceef6e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,7 +89,7 @@ data/osrm/* # Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports. # Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard). -reports/ +/reports/ # Log cruft at repo root debug.log diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx index f8f09d08..a4e784aa 100644 --- a/frontend/src/components/site-finder/analysis/ExportButtons.tsx +++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx @@ -9,9 +9,14 @@ */ import { useState } from "react"; -import { Download, FileText } from "lucide-react"; +import { Download, FileText, FileBarChart } from "lucide-react"; import { API_BASE_URL } from "@/lib/api"; import { triggerDownload } from "@/lib/download"; +import { + useFullReport, + formatElapsed, + formatReportDate, +} from "@/hooks/useFullReport"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; interface Props { @@ -51,10 +56,7 @@ function buildCsvRows( if (egrn) { rows.push( ["Адрес", egrn.address], - [ - "Площадь м²", - Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : "", - ], + ["Площадь м²", Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : ""], ["ВРИ", egrn.vri], ["Категория", egrn.category], ["Форма собственности", egrn.owner_type], @@ -97,6 +99,11 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) { const [csvLoading, setCsvLoading] = useState(false); const [error, setError] = useState(null); + // Полный PDF-отчёт (#2259 PR-E) — общий хук enqueue → poll → download. + const fullReport = useFullReport(cad); + const reportBuilding = fullReport.uiState === "building"; + const reportReady = fullReport.uiState === "ready"; + async function handlePdfDownload() { setPdfLoading(true); setError(null); @@ -193,8 +200,66 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) { {csvLoading ? "Генерация..." : "Скачать CSV"} + + {/* Full-report PDF — async job (#2259 PR-E): enqueue → poll → download */} + + {/* Full-report timeout / enqueue-error note (contract has no "failed") */} + {fullReport.uiState === "timeout" && ( +

+ Сборка затянулась — попробуйте позже +

+ )} + {fullReport.uiState === "error" && ( +

+ Не удалось поставить сборку отчёта — повторите +

+ )} + {/* Error message */} {error && (

- {/* Full-report DOCX — NO endpoint → honest disabled «скоро» */} -

+ fullReport.uiState === "ready" + ? fullReport.download() + : fullReport.start() + } + disabled={fullReport.uiState === "building"} + aria-label={ + fullReport.uiState === "ready" + ? "Скачать полный отчёт в формате PDF" + : "Собрать и скачать полный отчёт в формате PDF" + } > - DOCX + PDF - Полный отчёт - скоро + {fullReport.uiState === "building" + ? `Готовится… ${formatElapsed(fullReport.elapsedMs)}` + : fullReport.uiState === "ready" + ? `Скачать отчёт (${formatReportDate( + fullReport.reportGeneratedAt, + )})` + : "Полный отчёт"} + + + {fullReport.uiState === "timeout" + ? "Сборка затянулась — попробуйте позже" + : fullReport.uiState === "error" + ? "Не удалось поставить сборку — повторите" + : "Все секции · PDF"} - Редактируемый Word - -
+ {fullReport.uiState === "building" ? ( +
(см. triggerReportDownload) — + * не тянем blob отдельным fetch'ем, который пришлось бы отдельно авторизовывать. + */ + +// Поллинг: тик каждые 5с, cap ~3 мин (36 тиков) → стоп + сообщение. +const POLL_INTERVAL_MS = 5_000; +const POLL_CAP_MS = 3 * 60 * 1000; + +export type FullReportUiState = + | "idle" // ничего не запускалось / ещё не готов + | "building" // джоба идёт, поллим status + | "ready" // PDF готов, есть report_generated_at + | "timeout" // building провисел дольше cap — остановились + | "error"; // POST enqueue упал + +export interface UseFullReportResult { + uiState: FullReportUiState; + /** ISO дата генерации готового отчёта (для подписи «Скачать отчёт (дата)»). */ + reportGeneratedAt: string | null; + /** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */ + elapsedMs: number; + /** Клик по кнопке: enqueue → сразу download если ready, иначе старт поллинга. */ + start: () => void; + /** Ручное скачивание готового PDF (кнопка «Скачать отчёт»). */ + download: () => void; +} + +function buildReportPath(cad: string, suffix: string): string { + return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`; +} + +/** + * Навигация браузера к /report/download через скрытый . Наследует + * Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не + * нужен. Browser-only (использует document) — вызывать только из обработчика. + */ +function triggerReportDownload(cad: string): void { + if (typeof document === "undefined") return; + const a = document.createElement("a"); + a.href = `${API_BASE_URL}${buildReportPath(cad, "/download")}`; + // Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут + // без значения лишь подтверждает намерение скачать, не переопределяя имя. + a.download = ""; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +export function useFullReport(cad: string): UseFullReportResult { + // Флаг активного поллинга — заводится на building, гасится на ready/timeout. + const [polling, setPolling] = useState(false); + const [timedOut, setTimedOut] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + // Момент начала сборки — источник истины для elapsed и cap. + const startedAtRef = useRef(null); + // На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы + // загрузку снова. + const autoDownloadedRef = useRef(false); + + // ── POST enqueue ──────────────────────────────────────────────────────────── + const enqueue = useMutation({ + mutationFn: async (): Promise => { + // apiFetchWithStatus терпит 202 Accepted без throw'а (как async-джобы §22). + const { body } = await apiFetchWithStatus( + buildReportPath(cad, ""), + { method: "POST" }, + ); + return body; + }, + }); + + // ── GET status polling ──────────────────────────────────────────────────────── + const statusQuery = useQuery({ + queryKey: ["full-report-status", cad], + queryFn: (): Promise => + apiFetch(buildReportPath(cad, "/status")), + // enabled=false вне активной сборки → нет фонового поллинга и утечки после + // ухода со страницы (unmount снимает подписку, а enabled сам гасит тик). + enabled: !!cad && polling, + // Тик каждые POLL_INTERVAL_MS пока building; на ready — стоп (false). + refetchInterval: (query) => + query.state.data?.status === "ready" ? false : POLL_INTERVAL_MS, + refetchIntervalInBackground: false, + gcTime: 0, + }); + + const statusData = statusQuery.data; + + // ── Тикающий elapsed + cap-стоп (только пока идёт поллинг) ──────────────────── + useEffect(() => { + if (!polling) return; + if (startedAtRef.current === null) startedAtRef.current = Date.now(); + + const tick = () => { + const started = startedAtRef.current; + if (started === null) return; + const el = Date.now() - started; + setElapsedMs(el); + if (el >= POLL_CAP_MS) { + setPolling(false); + setTimedOut(true); + } + }; + tick(); + const id = window.setInterval(tick, 1000); + // Cleanup на unmount / смене polling → интервал не течёт после ухода. + return () => window.clearInterval(id); + }, [polling]); + + // ── На ready: авто-download один раз + стоп поллинга ────────────────────────── + useEffect(() => { + if (statusData?.status === "ready" && polling) { + setPolling(false); + if (!autoDownloadedRef.current) { + autoDownloadedRef.current = true; + triggerReportDownload(cad); + } + } + }, [statusData?.status, polling, cad]); + + // ── Смена участка: полный сброс прогона ────────────────────────────────────── + // Оба потребителя живут под route-сегментом [cad] (смена участка = remount), + // этот эффект — страховка на случай будущего in-place реюза хука с другим cad. + useEffect(() => { + setPolling(false); + setTimedOut(false); + setElapsedMs(0); + autoDownloadedRef.current = false; + startedAtRef.current = null; + }, [cad]); + + // ── Действие: клик по кнопке ────────────────────────────────────────────────── + const start = useCallback(() => { + // Сброс прошлого прогона (retry после timeout/error). + setTimedOut(false); + setElapsedMs(0); + 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]); + + const download = useCallback(() => { + triggerReportDownload(cad); + }, [cad]); + + // ── Производное UI-состояние ────────────────────────────────────────────────── + let uiState: FullReportUiState = "idle"; + if (statusData?.status === "ready" || enqueue.data?.status === "ready") { + uiState = "ready"; + } else if (timedOut) { + uiState = "timeout"; + } else if (polling || enqueue.isPending) { + uiState = "building"; + } else if (enqueue.isError) { + uiState = "error"; + } + + const reportGeneratedAt = + (uiState === "ready" ? statusData?.report_generated_at : null) ?? null; + + return { uiState, reportGeneratedAt, elapsedMs, start, download }; +} + +// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ──────────────────── + +/** «мм:сс» из миллисекунд — для «Готовится… 1:05». */ +export function formatElapsed(ms: number): string { + const totalSec = Math.floor(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${String(sec).padStart(2, "0")}`; +} + +/** ISO → «ru-RU» дата генерации отчёта (репо-конвенция, NaN-fallback). */ +export function formatReportDate(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + // new Date(мусор) не бросает, а даёт Invalid Date — try/catch тут мёртв. + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} -- 2.45.3