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
Стресс-свип 2026-07-04 (7 участков): первый HEAD download сразу после status=ready может словить транзиентный 502 (proxy-гонка на моменте записи файла), ретрей через ~30с — чистый 200. Прежний preflight по !res.ok показывал на это «Пересоберите отчёт» — вводит в заблуждение, файл цел. Теперь: 5xx → один ретрай через 1.5с; устойчивый не-OK → честное «Не удалось скачать — повторите через минуту»; 404 (легаси-ран без docx) — прежняя просьба пересобрать. onError теперь несёт message.
355 lines
18 KiB
TypeScript
355 lines
18 KiB
TypeScript
"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",
|
||
});
|
||
}
|