feat(site-finder): кнопка «Полный отчёт (PDF)» — enqueue → поллинг → download (#2259 PR-E)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m55s
CI / openapi-codegen-check (pull_request) Successful in 2m56s

Общий хук useFullReport(cad): POST /report (200 ready → мгновенный
download; 202 building → TanStack-поллинг status 5с с cap 3 мин →
честный timeout «Сборка затянулась — попробуйте позже»), авто-download
по ready (ref-guard, один раз), скачивание скрытым <a download> —
наследует 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
This commit is contained in:
bot-backend 2026-07-03 16:14:11 +05:00
parent 034d9f9bca
commit 1312dec0e6
4 changed files with 356 additions and 16 deletions

2
.gitignore vendored
View file

@ -89,7 +89,7 @@ data/osrm/*
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports. # Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard). # Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
reports/ /reports/
# Log cruft at repo root # Log cruft at repo root
debug.log debug.log

View file

@ -9,9 +9,14 @@
*/ */
import { useState } from "react"; 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 { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download"; import { triggerDownload } from "@/lib/download";
import {
useFullReport,
formatElapsed,
formatReportDate,
} from "@/hooks/useFullReport";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
interface Props { interface Props {
@ -51,10 +56,7 @@ function buildCsvRows(
if (egrn) { if (egrn) {
rows.push( rows.push(
["Адрес", egrn.address], ["Адрес", egrn.address],
[ ["Площадь м²", Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : ""],
"Площадь м²",
Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : "",
],
["ВРИ", egrn.vri], ["ВРИ", egrn.vri],
["Категория", egrn.category], ["Категория", egrn.category],
["Форма собственности", egrn.owner_type], ["Форма собственности", egrn.owner_type],
@ -97,6 +99,11 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
const [csvLoading, setCsvLoading] = useState(false); const [csvLoading, setCsvLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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() { async function handlePdfDownload() {
setPdfLoading(true); setPdfLoading(true);
setError(null); setError(null);
@ -193,8 +200,66 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
<Download size={14} strokeWidth={1.5} /> <Download size={14} strokeWidth={1.5} />
{csvLoading ? "Генерация..." : "Скачать CSV"} {csvLoading ? "Генерация..." : "Скачать CSV"}
</button> </button>
{/* Full-report PDF — async job (#2259 PR-E): enqueue → poll → download */}
<button
onClick={() =>
reportReady ? fullReport.download() : fullReport.start()
}
disabled={reportBuilding}
aria-label={
reportReady
? "Скачать полный отчёт PDF"
: "Собрать и скачать полный отчёт PDF"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: reportBuilding
? "var(--bg-card-alt)"
: "var(--accent-2)",
color: reportBuilding ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: reportBuilding ? "not-allowed" : "pointer",
opacity: reportBuilding ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<FileBarChart size={14} strokeWidth={1.5} />
{reportBuilding
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
: reportReady
? `Скачать отчёт (${formatReportDate(
fullReport.reportGeneratedAt,
)})`
: "Полный отчёт (PDF)"}
</button>
</div> </div>
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
{fullReport.uiState === "timeout" && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
role="alert"
>
Сборка затянулась попробуйте позже
</p>
)}
{fullReport.uiState === "error" && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
role="alert"
>
Не удалось поставить сборку отчёта повторите
</p>
)}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<p <p

View file

@ -30,6 +30,11 @@ import { FileText, Download, Presentation, FileType, Lock } from "lucide-react";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download"; import { triggerDownload } from "@/lib/download";
import { useParcelForecastQuery } from "@/lib/site-finder-api"; import { useParcelForecastQuery } from "@/lib/site-finder-api";
import {
useFullReport,
formatElapsed,
formatReportDate,
} from "@/hooks/useFullReport";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
@ -130,6 +135,9 @@ export function PticaReports({ cad, analysis }: Props) {
const { data: forecastData } = useParcelForecastQuery(cad); const { data: forecastData } = useParcelForecastQuery(cad);
const forecastReady = forecastData?.status === "ready"; const forecastReady = forecastData?.status === "ready";
// Полный PDF-отчёт (#2259 PR-E) — общий хук enqueue → poll → download.
const fullReport = useFullReport(cad);
async function handlePdf() { async function handlePdf() {
setBusy("pdf"); setBusy("pdf");
setError(null); setError(null);
@ -308,21 +316,55 @@ export function PticaReports({ cad, analysis }: Props) {
/> />
</button> </button>
{/* Full-report DOCX — NO endpoint → honest disabled «скоро» */} {/* Full-report PDF — REAL async job (#2259 PR-E): enqueue → poll → download */}
<div <button
className={`${styles.exportCard} ${styles.exportCardDisabled}`} type="button"
aria-disabled="true" className={styles.exportCard}
onClick={() =>
fullReport.uiState === "ready"
? fullReport.download()
: fullReport.start()
}
disabled={fullReport.uiState === "building"}
aria-label={
fullReport.uiState === "ready"
? "Скачать полный отчёт в формате PDF"
: "Собрать и скачать полный отчёт в формате PDF"
}
> >
<span className={`${styles.fmt} ${styles.fmtMuted}`}>DOCX</span> <span className={`${styles.fmt} ${styles.fmtPdf}`}>PDF</span>
<span className={styles.exportMeta}> <span className={styles.exportMeta}>
<span className={styles.exportTitle}> <span className={styles.exportTitle}>
Полный отчёт {fullReport.uiState === "building"
<span className={styles.exportTagSoon}>скоро</span> ? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
: fullReport.uiState === "ready"
? `Скачать отчёт (${formatReportDate(
fullReport.reportGeneratedAt,
)})`
: "Полный отчёт"}
</span>
<span className={styles.exportDesc}>
{fullReport.uiState === "timeout"
? "Сборка затянулась — попробуйте позже"
: fullReport.uiState === "error"
? "Не удалось поставить сборку — повторите"
: "Все секции · PDF"}
</span> </span>
<span className={styles.exportDesc}>Редактируемый Word</span>
</span> </span>
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} /> {fullReport.uiState === "building" ? (
</div> <span
className={styles.pendingPulse}
style={{ width: 16, height: 16, borderWidth: 2 }}
aria-hidden="true"
/>
) : (
<FileText
size={16}
strokeWidth={1.5}
className={styles.exportIco}
/>
)}
</button>
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */} {/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
<div <div

View file

@ -0,0 +1,233 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { API_BASE_URL, apiFetch, apiFetchWithStatus } from "@/lib/api";
import type { components } from "@/lib/api-types";
// Reuse generated OpenAPI types (api-types.ts) — НЕ дублировать руками (#2259 PR-D).
export type ReportBuildResponse = components["schemas"]["ReportBuildResponse"];
export type ReportStatusResponse =
components["schemas"]["ReportStatusResponse"];
/**
* useFullReport единый хук кнопки «Полный отчёт (PDF)» (#2259 PR-E).
*
* Переиспользуется двумя компонентами (ptica/reports/PticaReports «тёмная»
* витрина экспортов + analysis/ExportButtons «светлая» страница) вся логика
* enqueue poll download живёт здесь, компоненты только рендерят состояние.
*
* Контракт бэкенда (PR-D #2287):
* POST /api/v1/parcels/{cad}/report
* 202 { status: "building" } джоба поставлена, PDF собирается в фоне
* 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)
*
* «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS,
* фронт сам останавливает поллинг и показывает «Сборка затянулась попробуйте
* позже» (uiState === "timeout"), кнопка снова активна.
*
* Скачивание: страница за Caddy basic_auth. Обычная навигация браузера к
* /report/download наследует уже авторизованную сессию (cookie/basic-auth),
* поэтому качаем через скрытый <a href download> (см. 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 через скрытый <a 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<number | null>(null);
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
// загрузку снова.
const autoDownloadedRef = useRef(false);
// ── POST enqueue ────────────────────────────────────────────────────────────
const enqueue = useMutation({
mutationFn: async (): Promise<ReportBuildResponse> => {
// apiFetchWithStatus терпит 202 Accepted без throw'а (как async-джобы §22).
const { body } = await apiFetchWithStatus<ReportBuildResponse>(
buildReportPath(cad, ""),
{ method: "POST" },
);
return body;
},
});
// ── GET status polling ────────────────────────────────────────────────────────
const statusQuery = useQuery({
queryKey: ["full-report-status", cad],
queryFn: (): Promise<ReportStatusResponse> =>
apiFetch<ReportStatusResponse>(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",
});
}