gendesign/frontend/src/hooks/useFullReport.ts
bot-backend 786739f0a6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 59s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
fix(report-ui): ретрай docx-preflight против 502-гонки сразу после ready
Стресс-свип 2026-07-04 (7 участков): первый HEAD download сразу после
status=ready может словить транзиентный 502 (proxy-гонка на моменте
записи файла), ретрей через ~30с — чистый 200. Прежний preflight по
!res.ok показывал на это «Пересоберите отчёт» — вводит в заблуждение,
файл цел. Теперь: 5xx → один ретрай через 1.5с; устойчивый не-OK →
честное «Не удалось скачать — повторите через минуту»; 404 (легаси-ран
без docx) — прежняя просьба пересобрать. onError теперь несёт message.
2026-07-04 05:00:09 +05:00

355 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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?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. Чтобы <a download> не «молча» скачивал HTML-страницу ошибки, DOCX-скачивание
* идёт через HEAD-preflight (triggerReportDownload с preflight): 404 → onDownloadError
* («Пересоберите отчёт»), ok → навигация <a>. PDF-путь preflight не нужен (файл всегда
* есть при ready), остаётся прямой <a>-навигацией.
*
* «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;
/** Формат финального download'а. Один прогон enqueue→poll — формат только у скачивания. */
export type ReportFormat = "pdf" | "docx";
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 в `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 через скрытый <a download>. Наследует
* Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не
* нужен. Browser-only (использует document) — вызывать только из обработчика.
*/
function triggerAnchorDownload(cad: string, format: ReportFormat): void {
if (typeof document === "undefined") return;
const a = document.createElement("a");
a.href = downloadUrl(cad, format);
// Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут
// без значения лишь подтверждает намерение скачать, не переопределяя имя.
a.download = "";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/**
* Скачать отчёт в `format`. PDF-путь — прямая <a>-навигация (файл всегда есть при
* ready). DOCX-путь — HEAD-preflight: легаси-раны без docx_path отдают 404, а
* <a download> на 404 «молча» скачал бы HTML-страницу ошибки; ловим здесь и зовём
* onError («Пересоберите отчёт»). HEAD дешевле GET (тело не тянется) и наследует
* ту же basic_auth сессию, что и <a>. Сеть упала — не блокируем: пробуем <a>.
*/
async function triggerReportDownload(
cad: string,
format: ReportFormat,
onError: (message: string) => void,
): Promise<void> {
if (format === "pdf") {
triggerAnchorDownload(cad, "pdf");
return;
}
try {
let res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" });
// 5xx сразу после status=ready — транзиентная proxy-гонка (стресс-свип
// 2026-07-04: первый HEAD после ready ловил 502, ретрей через ~1.5с — 200).
// Один ретрай, прежде чем показывать пользователю ошибку.
if (!res.ok && res.status !== 404) {
await new Promise((r) => setTimeout(r, 1500));
res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" });
}
if (res.status === 404) {
// Легаси-ран без docx_path — честная просьба пересобрать.
onError("Пересоберите отчёт — DOCX для старого прогона отсутствует");
return;
}
if (!res.ok) {
// Устойчивый не-OK (5xx и после ретрая) — не даём <a> молча скачать
// страницу ошибки, но и НЕ просим пересобрать (файл скорее всего цел).
onError("Не удалось скачать отчёт — повторите через минуту");
return;
}
} catch {
// Сетевая ошибка preflight'а (offline / CORS) — не проглатываем download:
// отдаём <a>-навигации, пусть браузер сам разрулит.
}
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<string | null>(null);
// Момент начала сборки — источник истины для elapsed и cap.
const startedAtRef = useRef<number | null>(null);
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
// загрузку снова.
const autoDownloadedRef = useRef(false);
// Формат финального авто-download'а — «кто кликнул, тот формат и качается».
// Ref (не state): читается только внутри обработчиков/эффекта, ре-рендер под
// него не нужен, и это держит один общий прогон без дубля состояния формата.
const pendingFormatRef = useRef<ReportFormat>("pdf");
// Стабильный колбэк ошибки docx-download'а для triggerReportDownload.
const handleDownloadError = useCallback((message: string) => {
setDownloadError(message);
}, []);
// ── 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;
void triggerReportDownload(
cad,
pendingFormatRef.current,
handleDownloadError,
);
}
}
}, [statusData?.status, polling, cad, handleDownloadError]);
// ── Смена участка: полный сброс прогона ──────────────────────────────────────
// Оба потребителя живут под route-сегментом [cad] (смена участка = remount),
// этот эффект — страховка на случай будущего in-place реюза хука с другим cad.
useEffect(() => {
setPolling(false);
setTimedOut(false);
setElapsedMs(0);
setDownloadError(null);
autoDownloadedRef.current = false;
startedAtRef.current = null;
pendingFormatRef.current = "pdf";
}, [cad]);
// ── Действие: клик по кнопке ──────────────────────────────────────────────────
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;
void triggerReportDownload(cad, format, handleDownloadError);
} else {
// building → стартуем поллинг status.
startedAtRef.current = Date.now();
setPolling(true);
}
},
});
},
[enqueue, cad, handleDownloadError],
);
const download = useCallback(
(format: ReportFormat = "pdf") => {
setDownloadError(null);
void triggerReportDownload(cad, format, handleDownloadError);
},
[cad, handleDownloadError],
);
// ── Производное 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 ?? enqueue.data?.report_generated_at)
: null) ?? null;
return {
uiState,
reportGeneratedAt,
elapsedMs,
start,
download,
downloadError,
};
}
// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ────────────────────
/** «мм:сс» из миллисекунд — для «Готовится… 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")}`;
}
/** «Скачать отчёт (дата)»; на fast-path из кэша даты ещё нет — без пустых скобок. */
export function readyReportLabel(iso: string | null): string {
const d = formatReportDate(iso);
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 "";
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",
});
}