validate([ 'status' => ['nullable', 'string', Rule::in([ ReportJob::STATUS_PENDING, ReportJob::STATUS_PROCESSING, ReportJob::STATUS_DONE, ReportJob::STATUS_FAILED, ])], 'limit' => 'nullable|integer|min:1|max:200', 'offset' => 'nullable|integer|min:0', ]); /** @var User $user */ $user = $request->user(); $limit = (int) ($validated['limit'] ?? 50); $offset = (int) ($validated['offset'] ?? 0); return DB::transaction(function () use ($user, $validated, $limit, $offset): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); $query = ReportJob::query()->where('tenant_id', $user->tenant_id); if (isset($validated['status'])) { $query->where('status', $validated['status']); } $total = (clone $query)->count(); $items = $query->orderByDesc('created_at')->orderByDesc('id') ->limit($limit)->offset($offset)->get(); $base = ReportJob::query()->where('tenant_id', $user->tenant_id); $counts = [ 'pending' => (clone $base)->where('status', ReportJob::STATUS_PENDING)->count(), 'processing' => (clone $base)->where('status', ReportJob::STATUS_PROCESSING)->count(), 'done' => (clone $base)->where('status', ReportJob::STATUS_DONE)->count(), 'failed' => (clone $base)->where('status', ReportJob::STATUS_FAILED)->count(), ]; return response()->json([ 'jobs' => $items->map(fn (ReportJob $j) => $this->toResource($j))->all(), 'total' => $total, 'limit' => $limit, 'offset' => $offset, 'counts' => $counts, 'quota' => [ 'active' => $counts['pending'] + $counts['processing'], 'max_active' => self::MAX_ACTIVE_JOBS_PER_TENANT, ], ]); }); } /** * GET /api/reports/jobs/{id} */ public function show(Request $request, int $id): JsonResponse { /** @var User $user */ $user = $request->user(); return DB::transaction(function () use ($user, $id): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); $job = ReportJob::query() ->where('id', $id) ->where('tenant_id', $user->tenant_id) ->first(); if ($job === null) { return response()->json(['message' => 'Отчёт не найден.'], 404); } return response()->json(['job' => $this->toResource($job)]); }); } /** * POST /api/reports/jobs {type, format, parameters: {...}} */ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'type' => ['required', 'string', Rule::in(ReportJob::TYPES)], 'format' => ['required', 'string', Rule::in(ReportJob::FORMATS)], 'parameters' => 'required|array', 'parameters.date_from' => 'required|date', 'parameters.date_to' => 'required|date|after_or_equal:parameters.date_from', 'parameters.project_id' => 'nullable|integer|min:1', 'parameters.manager_id' => 'nullable|integer|min:1', ]); /** @var User $user */ $user = $request->user(); return DB::transaction(function () use ($user, $validated): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); // CTO-7: квота 3 одновременных pending+processing на тенант. $activeCount = ReportJob::query() ->where('tenant_id', $user->tenant_id) ->whereIn('status', ReportJob::ACTIVE_STATUSES) ->count(); if ($activeCount >= self::MAX_ACTIVE_JOBS_PER_TENANT) { return response()->json([ 'message' => 'Лимит активных отчётов исчерпан.', 'errors' => ['_quota' => [ sprintf( 'Максимум %d одновременных отчётов на тенант. Дождитесь завершения или удалите ненужные.', self::MAX_ACTIVE_JOBS_PER_TENANT ), ]], ], 422); } $job = ReportJob::create([ 'tenant_id' => $user->tenant_id, 'user_id' => $user->id, 'type' => $validated['type'], 'parameters' => [ 'format' => $validated['format'], 'date_from' => $validated['parameters']['date_from'], 'date_to' => $validated['parameters']['date_to'], 'project_id' => $validated['parameters']['project_id'] ?? null, 'manager_id' => $validated['parameters']['manager_id'] ?? null, ], 'status' => ReportJob::STATUS_PENDING, ]); // Sync queue на dev — Job выполняется немедленно. // На prod queue.driver=redis/database — async через worker. GenerateReportJob::dispatch($job->id, (int) $user->tenant_id); return response()->json([ 'job' => $this->toResource($job->fresh()), ], 201); }); } /** * POST /api/reports/jobs/{id}/retry — CTO-6: 3 попытки за 7 дней, только owner. * Создаёт НОВЫЙ ReportJob (parameters.retry_count = old + 1) + dispatch. * Старый failed-job остаётся для истории. */ public function retry(Request $request, int $id): JsonResponse { /** @var User $user */ $user = $request->user(); return DB::transaction(function () use ($user, $id): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); $original = ReportJob::query() ->where('id', $id) ->where('tenant_id', $user->tenant_id) ->first(); if ($original === null) { return response()->json(['message' => 'Отчёт не найден.'], 404); } if ($original->user_id !== $user->id) { return response()->json(['message' => 'Повторно запустить может только владелец отчёта.'], 403); } if ($original->status !== ReportJob::STATUS_FAILED) { return response()->json([ 'message' => 'Повтор доступен только для отчётов со статусом «failed».', 'errors' => ['status' => ['Текущий статус: '.$original->status]], ], 422); } $retryCount = (int) ($original->parameters['retry_count'] ?? 0); if ($retryCount >= self::RETRY_MAX_ATTEMPTS - 1) { return response()->json([ 'message' => 'Достигнут максимум попыток.', 'errors' => ['_retry' => [sprintf('Максимум %d попыток (CTO-6).', self::RETRY_MAX_ATTEMPTS)]], ], 422); } $cutoff = Carbon::now()->subDays(self::RETRY_WINDOW_DAYS); if ($original->created_at !== null && $original->created_at->lt($cutoff)) { return response()->json([ 'message' => 'Окно повторов истекло.', 'errors' => ['_retry' => [sprintf('Повтор доступен в течение %d дней с создания.', self::RETRY_WINDOW_DAYS)]], ], 422); } // Квота тоже учитывается для retry — иначе можно «обойти» CTO-7 через retry-spam. $activeCount = ReportJob::query() ->where('tenant_id', $user->tenant_id) ->whereIn('status', ReportJob::ACTIVE_STATUSES) ->count(); if ($activeCount >= self::MAX_ACTIVE_JOBS_PER_TENANT) { return response()->json([ 'message' => 'Лимит активных отчётов исчерпан.', 'errors' => ['_quota' => [sprintf('Максимум %d одновременных отчётов на тенант.', self::MAX_ACTIVE_JOBS_PER_TENANT)]], ], 422); } $params = $original->parameters ?? []; $params['retry_count'] = $retryCount + 1; $params['retry_of'] = $original->id; $newJob = ReportJob::create([ 'tenant_id' => $user->tenant_id, 'user_id' => $user->id, 'type' => $original->type, 'parameters' => $params, 'status' => ReportJob::STATUS_PENDING, ]); GenerateReportJob::dispatch($newJob->id, (int) $user->tenant_id); return response()->json([ 'job' => $this->toResource($newJob->fresh()), ], 201); }); } /** * POST /api/reports/jobs/{id}/cancel — отменить pending (running уже работает). */ public function cancel(Request $request, int $id): JsonResponse { /** @var User $user */ $user = $request->user(); return DB::transaction(function () use ($user, $id): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); $job = ReportJob::query() ->where('id', $id) ->where('tenant_id', $user->tenant_id) ->first(); if ($job === null) { return response()->json(['message' => 'Отчёт не найден.'], 404); } if ($job->user_id !== $user->id) { return response()->json(['message' => 'Отменить может только владелец отчёта.'], 403); } if ($job->status !== ReportJob::STATUS_PENDING) { return response()->json([ 'message' => 'Отменить можно только pending-задачу.', 'errors' => ['status' => ['Текущий статус: '.$job->status]], ], 422); } $job->update([ 'status' => ReportJob::STATUS_FAILED, 'error_message' => 'Отменено пользователем.', 'finished_at' => Carbon::now(), ]); return response()->json(['job' => $this->toResource($job->fresh())]); }); } /** * DELETE /api/reports/jobs/{id} — удалить terminal job + файл. */ public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse { /** @var User $user */ $user = $request->user(); return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id); $job = ReportJob::query() ->where('id', $id) ->where('tenant_id', $user->tenant_id) ->first(); if ($job === null) { return response()->json(['message' => 'Отчёт не найден.'], 404); } if ($job->user_id !== $user->id) { return response()->json(['message' => 'Удалить может только владелец отчёта.'], 403); } if (! in_array($job->status, ReportJob::TERMINAL_STATUSES, true)) { return response()->json([ 'message' => 'Удалить можно только завершённую задачу (done|failed).', 'errors' => ['status' => ['Текущий статус: '.$job->status]], ], 422); } if ($job->file_path !== null) { Storage::disk('local')->delete($job->file_path); $pdLog->record( action: 'deleted', subjectType: 'lead', subjectId: null, purpose: 'report_file_'.$job->id, tenantId: (int) $job->tenant_id, actorTenantUserId: (int) $user->id, actorAdminUserId: null, ip: $request->ip(), ); } $job->delete(); return response()->json(['message' => 'Удалено.']); }); } /** * GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= — скачать * готовый файл отчёта (F2, OPEN-И-20). * * Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token. * `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а). * Подпись покрывает все query-параметры — `tenant`/`id` подделать нельзя. */ public function download(Request $request, int $id): Response { $tenantId = (int) $request->query('tenant', '0'); return DB::transaction(function () use ($id, $tenantId): Response { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); $job = ReportJob::query() ->where('id', $id) ->where('tenant_id', $tenantId) ->first(); if ($job === null) { return response()->json(['message' => 'Отчёт не найден.'], 404); } if ($job->status !== ReportJob::STATUS_DONE || $job->file_path === null || ($job->expires_at !== null && $job->expires_at->isPast())) { return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410); } if (! Storage::disk('local')->exists($job->file_path)) { return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404); } $extension = pathinfo($job->file_path, PATHINFO_EXTENSION); return Storage::disk('local')->download( $job->file_path, sprintf('report-%d.%s', $job->id, $extension) ); }); } /** * Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или * после истечения retention (file_path обнулён cron'ом reports:cleanup-expired). */ private function downloadUrl(ReportJob $job): ?string { if ($job->status !== ReportJob::STATUS_DONE || $job->file_path === null || ($job->expires_at !== null && $job->expires_at->isPast())) { return null; } return URL::temporarySignedRoute( 'reports.download', Carbon::now()->addHours(24), ['id' => $job->id, 'tenant' => $job->tenant_id], ); } /** @return array */ private function toResource(ReportJob $job): array { return [ 'id' => $job->id, 'type' => $job->type, 'parameters' => $job->parameters, 'status' => $job->status, 'file_path' => $job->file_path, 'file_size' => $job->file_size, 'generation_seconds' => $job->generation_seconds, 'error_message' => $job->error_message, 'created_at' => $job->created_at?->toIso8601String(), 'finished_at' => $job->finished_at?->toIso8601String(), 'expires_at' => $job->expires_at?->toIso8601String(), 'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(), 'retry_count' => (int) ($job->parameters['retry_count'] ?? 0), 'retry_max' => self::RETRY_MAX_ATTEMPTS, 'download_url' => $this->downloadUrl($job), ]; } }