← Назад к вопросам

Как реализовать отложенное получение отчета?

2.3 Middle🔥 131 комментариев
#Soft Skills и рабочие процессы

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Отложенное получение отчета (Deferred Report Loading)

Отложенное получение отчета — это техника, при которой отчет не загружается и не генерируется сразу, а инициируется асинхронно, и пользователь может отслеживать прогресс выполнения. Это особенно полезно для больших отчетов, которые требуют обработки значительного объема данных.

Архитектура решения

Сценарий использования

  1. Пользователь нажимает кнопку "Скачать отчет"
  2. Сервер получает запрос и начинает генерировать отчет в фоне
  3. Клиент получает job ID и может отслеживать статус
  4. Когда отчет готов, клиент может скачать его

Общий поток

Cluster       Клиент                      Сервер
               |
               |---- POST /reports/generate ---->
               |                                |
               |<-- 202 Accepted + jobId ----|
               |                                |
               |---- GET /reports/{jobId} ----->|  (polling или WebSocket)
               |<-- 200 { status: progress } --||
               |                                ||
               |---- GET /reports/{jobId} ----->|  (повторяется)
               |<-- 200 { status: completed } -|
               |                                |
               |---- GET /reports/{jobId}/download ---->
               |<-- 200 (file) ---------------|

Реализация на фронтенде

1. Service для работы с отчетами

import { api } from '@/lib/api';

export class ReportService {
  private readonly baseUrl = '/api/v1/reports';

  // Инициируем генерацию отчета
  async generateReport(params: ReportParams): Promise<string> {
    const response = await api.post(`${this.baseUrl}/generate`, params);
    return response.data.jobId;  // Сервер возвращает jobId
  }

  // Получаем статус отчета
  async getReportStatus(jobId: string): Promise<ReportStatus> {
    const response = await api.get(`${this.baseUrl}/${jobId}`);
    return response.data;
  }

  // Скачиваем готовый отчет
  async downloadReport(jobId: string): Promise<Blob> {
    const response = await api.get(
      `${this.baseUrl}/${jobId}/download`,
      { responseType: 'blob' }
    );
    return response.data;
  }

  // Отменяем генерацию отчета
  async cancelReport(jobId: string): Promise<void> {
    await api.delete(`${this.baseUrl}/${jobId}`);
  }
}

export interface ReportParams {
  type: 'sales' | 'inventory' | 'users';
  dateFrom: string;
  dateTo: string;
  format: 'pdf' | 'excel' | 'csv';
}

export interface ReportStatus {
  jobId: string;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  progress: number;  // 0-100
  message?: string;
  error?: string;
  completedAt?: string;
}

export const reportService = new ReportService();

2. Custom Hook для генерации отчета

import { useState, useCallback, useRef } from 'react';
import { reportService, type ReportStatus, type ReportParams } from '@/services/reports';

export function useReportGeneration() {
  const [status, setStatus] = useState<ReportStatus | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);

  // Начать генерацию отчета
  const generateReport = useCallback(async (params: ReportParams) => {
    setLoading(true);
    setError(null);
    setStatus(null);

    try {
      // Инициируем генерацию
      const jobId = await reportService.generateReport(params);
      setStatus({
        jobId,
        status: 'pending',
        progress: 0
      });

      // Начинаем отслеживать прогресс
      pollReportStatus(jobId);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Ошибка при создании отчета';
      setError(message);
      setLoading(false);
    }
  }, []);

  // Отслеживаем статус отчета (polling)
  const pollReportStatus = useCallback((jobId: string) => {
    const poll = async () => {
      try {
        const reportStatus = await reportService.getReportStatus(jobId);
        setStatus(reportStatus);

        // Если отчет готов или произошла ошибка — останавливаем polling
        if (
          reportStatus.status === 'completed' ||
          reportStatus.status === 'failed'
        ) {
          clearInterval(pollIntervalRef.current!);
          setLoading(false);
        }
      } catch (err) {
        const message = err instanceof Error ? err.message : 'Ошибка при получении статуса';
        setError(message);
        clearInterval(pollIntervalRef.current!);
        setLoading(false);
      }
    };

    // Первый запрос сразу, затем каждые 2 секунды
    poll();
    pollIntervalRef.current = setInterval(poll, 2000);
  }, []);

  // Скачать готовый отчет
  const downloadReport = useCallback(async () => {
    if (!status?.jobId) return;

    try {
      const blob = await reportService.downloadReport(status.jobId);
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = `report-${status.jobId}.pdf`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Ошибка при скачивании';
      setError(message);
    }
  }, [status?.jobId]);

  // Отменить генерацию
  const cancelReport = useCallback(async () => {
    if (!status?.jobId) return;

    try {
      await reportService.cancelReport(status.jobId);
      clearInterval(pollIntervalRef.current!);
      setStatus(null);
      setLoading(false);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Ошибка при отмене';
      setError(message);
    }
  }, [status?.jobId]);

  // Очистка при размонтировании компонента
  useEffect(() => {
    return () => {
      if (pollIntervalRef.current) {
        clearInterval(pollIntervalRef.current);
      }
    };
  }, []);

  return {
    status,
    loading,
    error,
    generateReport,
    downloadReport,
    cancelReport
  };
}

3. UI компонент для отчета

import React, { useState } from 'react';
import { useReportGeneration } from '@/hooks/useReportGeneration';
import type { ReportParams } from '@/services/reports';

export function ReportGenerator() {
  const [params, setParams] = useState<ReportParams>({
    type: 'sales',
    dateFrom: '2025-01-01',
    dateTo: '2025-04-02',
    format: 'pdf'
  });

  const { status, loading, error, generateReport, downloadReport, cancelReport } =
    useReportGeneration();

  const handleGenerateClick = () => {
    generateReport(params);
  };

  return (
    <div className="space-y-6">
      {/* Форма параметров */}
      <div className="border border-border-default rounded-lg p-4 space-y-4">
        <div>
          <label className="block text-sm font-medium text-content-primary mb-2">
            Тип отчета
          </label>
          <select
            value={params.type}
            onChange={(e) => setParams({ ...params, type: e.target.value as any })}
            disabled={loading}
            className="w-full px-3 py-2 border border-border-default rounded"
          >
            <option value="sales">Продажи</option>
            <option value="inventory">Инвентарь</option>
            <option value="users">Пользователи</option>
          </select>
        </div>

        <div>
          <label className="block text-sm font-medium text-content-primary mb-2">
            Формат
          </label>
          <select
            value={params.format}
            onChange={(e) => setParams({ ...params, format: e.target.value as any })}
            disabled={loading}
            className="w-full px-3 py-2 border border-border-default rounded"
          >
            <option value="pdf">PDF</option>
            <option value="excel">Excel</option>
            <option value="csv">CSV</option>
          </select>
        </div>

        <button
          onClick={handleGenerateClick}
          disabled={loading || status?.status === 'processing'}
          className="w-full px-4 py-2 bg-bg-primary text-white rounded disabled:opacity-50"
        >
          {loading ? 'Генерируем...' : 'Генерировать отчет'}
        </button>
      </div>

      {/* Статус и прогресс */}
      {status && (
        <div className="border border-border-default rounded-lg p-4 space-y-4">
          <div>
            <p className="text-sm text-content-secondary mb-2">Статус: {status.status}</p>
            <div className="w-full bg-bg-secondary rounded-full h-2">
              <div
                className="bg-bg-primary h-2 rounded-full transition-all duration-300"
                style={{ width: `${status.progress}%` }}
              />
            </div>
            <p className="text-xs text-content-secondary mt-2">{status.progress}%</p>
          </div>

          {status.message && (
            <p className="text-sm text-content-secondary">{status.message}</p>
          )}

          {status.status === 'completed' && (
            <button
              onClick={downloadReport}
              className="w-full px-4 py-2 bg-bg-primary text-white rounded hover:bg-opacity-90"
            >
              Скачать отчет
            </button>
          )}

          {status.status === 'processing' && (
            <button
              onClick={cancelReport}
              className="w-full px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
            >
              Отменить
            </button>
          )}
        </div>
      )}

      {/* Ошибки */}
      {error && (
        <div className="border border-red-300 bg-red-50 rounded-lg p-4">
          <p className="text-sm text-red-800">{error}</p>
        </div>
      )}
    </div>
  );
}

Альтернатива: WebSocket для real-time статуса

Вместо polling можно использовать WebSocket для более реактивного обновления:

export function useReportGenerationWebSocket() {
  const [status, setStatus] = useState<ReportStatus | null>(null);
  const wsRef = useRef<WebSocket | null>(null);

  const subscribeToReport = useCallback((jobId: string) => {
    const wsUrl = `wss://${window.location.host}/api/v1/reports/${jobId}/stream`;
    wsRef.current = new WebSocket(wsUrl);

    wsRef.current.onmessage = (event) => {
      const reportStatus = JSON.parse(event.data) as ReportStatus;
      setStatus(reportStatus);

      if (reportStatus.status === 'completed' || reportStatus.status === 'failed') {
        wsRef.current?.close();
      }
    };

    wsRef.current.onerror = (error) => {
      console.error('WebSocket error:', error);
      wsRef.current?.close();
    };
  }, []);

  useEffect(() => {
    return () => {
      wsRef.current?.close();
    };
  }, []);

  return { status, subscribeToReport };
}

Ключевые преимущества

  • Пользователь видит прогресс генерации
  • Сервер не блокируется на долгие операции
  • Можно отменить процесс
  • Масштабируемо для больших отчетов
  • Улучшенный UX благодаря feedback'у

Этот паттерн используется в production приложениях для любых долгих асинхронных операций: генерация отчетов, обработка видео, импорт данных.

Как реализовать отложенное получение отчета? | PrepBro