feat(ptica/reports): включить DOCX-скачивание полного отчёта (#2259 R2 UI)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
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 55s
CI / openapi-codegen-check (pull_request) Successful in 1m52s

Полный отчёт отдаёт и 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 остаётся «скоро» честно (бэкенда нет).
This commit is contained in:
bot-backend 2026-07-04 03:37:13 +05:00
parent f41ca530c5
commit bdd95063b6
3 changed files with 257 additions and 39 deletions

View file

@ -9,13 +9,14 @@
*/ */
import { useState } from "react"; 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 { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download"; import { triggerDownload } from "@/lib/download";
import { import {
useFullReport, useFullReport,
formatElapsed, formatElapsed,
readyReportLabel, readyReportLabel,
readyDocxLabel,
} from "@/hooks/useFullReport"; } from "@/hooks/useFullReport";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
@ -238,6 +239,46 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
? readyReportLabel(fullReport.reportGeneratedAt) ? readyReportLabel(fullReport.reportGeneratedAt)
: "Полный отчёт (PDF)"} : "Полный отчёт (PDF)"}
</button> </button>
{/*
Full-report DOCX вторичный компакт рядом с «Полный отчёт (PDF)».
Тот же общий прогон useFullReport (один enqueuepoll даёт pdf+docx),
финальный download в ?format=docx. Компактнее PDF-кнопки (иконка +
«DOCX»), чтобы не перегружать ряд. Building/ready-состояние общее с
PDF-кнопкой (обе дизейблятся на сборке).
*/}
<button
onClick={() =>
reportReady ? fullReport.download("docx") : fullReport.start("docx")
}
disabled={reportBuilding}
aria-label={
reportReady
? "Скачать полный отчёт Word"
: "Собрать и скачать полный отчёт Word"
}
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",
}}
>
<FileType size={14} strokeWidth={1.5} />
{reportReady ? readyDocxLabel(fullReport.reportGeneratedAt) : "DOCX"}
</button>
</div> </div>
{/* Full-report timeout / enqueue-error note (contract has no "failed") */} {/* Full-report timeout / enqueue-error note (contract has no "failed") */}
@ -257,6 +298,15 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
Не удалось поставить сборку отчёта повторите Не удалось поставить сборку отчёта повторите
</p> </p>
)} )}
{/* Легаси-ран без docx (собран до PR-F) → download?format=docx = 404. */}
{fullReport.downloadError && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
role="alert"
>
{fullReport.downloadError}
</p>
)}
{/* Error message */} {/* Error message */}
{error && ( {error && (

View file

@ -14,10 +14,17 @@
* being ready (useParcelForecastQuery): until then the cards are disabled * being ready (useParcelForecastQuery): until then the cards are disabled
* with an honest «готовится» tag. * with an honest «готовится» tag.
* *
* Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards) * Full-report DOCX (#2259 R2 PR-F) REAL: GET /report/download?format=docx.
* have NO backend endpoint the only Word/PowerPoint export the backend ships * One report build produces BOTH pdf + docx, so the DOCX card shares the SAME
* is the §22 forecast above. So those two cards render DISABLED with a «скоро» * useFullReport(cad) run as the full-report PDF card (enqueue poll download)
* tag (honest we never trigger a download that 404s). * 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 * The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
* (matches the prototype TOC); the PDF snapshot is the real multi-section * (matches the prototype TOC); the PDF snapshot is the real multi-section
@ -34,6 +41,7 @@ import {
useFullReport, useFullReport,
formatElapsed, formatElapsed,
readyReportLabel, readyReportLabel,
readyDocxLabel,
} from "@/hooks/useFullReport"; } 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";
@ -364,6 +372,55 @@ export function PticaReports({ cad, analysis }: Props) {
)} )}
</button> </button>
{/* Full-report DOCX REAL (#2259 R2 PR-F): same run as PDF card,
final download ?format=docx. Building-спиннер общий на прогон. */}
<button
type="button"
className={styles.exportCard}
onClick={() =>
fullReport.uiState === "ready"
? fullReport.download("docx")
: fullReport.start("docx")
}
disabled={fullReport.uiState === "building"}
aria-label={
fullReport.uiState === "ready"
? "Скачать полный отчёт в формате Word"
: "Собрать и скачать полный отчёт в формате Word"
}
>
<span className={`${styles.fmt} ${styles.fmtDocx}`}>DOCX</span>
<span className={styles.exportMeta}>
<span className={styles.exportTitle}>
{fullReport.uiState === "building"
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
: fullReport.uiState === "ready"
? readyDocxLabel(fullReport.reportGeneratedAt)
: "Полный отчёт"}
</span>
<span className={styles.exportDesc}>
{fullReport.uiState === "timeout"
? "Сборка затянулась — попробуйте позже"
: fullReport.uiState === "error"
? "Не удалось поставить сборку — повторите"
: "Все секции · Word"}
</span>
</span>
{fullReport.uiState === "building" ? (
<span
className={styles.pendingPulse}
style={{ width: 16, height: 16, borderWidth: 2 }}
aria-hidden="true"
/>
) : (
<FileType
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
className={`${styles.exportCard} ${styles.exportCardDisabled}`} className={`${styles.exportCard} ${styles.exportCardDisabled}`}
@ -387,6 +444,14 @@ export function PticaReports({ cad, analysis }: Props) {
</p> </p>
)} )}
{/* Легаси-раны без docx (собраны до PR-F) download?format=docx = 404.
Хук ловит это HEAD-preflight'ом и просит пересобрать отчёт. */}
{fullReport.downloadError && (
<p className={styles.reportError} role="alert">
{fullReport.downloadError}
</p>
)}
{/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */} {/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */}
<hr className={styles.overlayDivider} /> <hr className={styles.overlayDivider} />
<h3 className={styles.cardTitle}> <h3 className={styles.cardTitle}>

View file

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