with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса) ->where('tenant_id', $request->user()->tenant_id); // Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.) if ($ids = $request->query('ids')) { // '?ids=' batch fetch. Non-numeric and zero values silently dropped via intval+filter // (intval('abc')=0 → array_filter drops 0). Acceptable for a read-only dropdown: // invalid input produces empty result, not 422. $idArray = array_filter(array_map('intval', explode(',', (string) $ids))); $items = $query->whereIn('id', $idArray)->get(); return response()->json(['data' => ProjectResource::collection($items)]); } // Фильтр по типу сигнала if ($type = $request->query('signal_type')) { $query->where('signal_type', $type); } // Фильтр по статусу жизненного цикла $status = $request->query('status'); if ($status === 'active') { $query->where('is_active', true); } elseif ($status === 'paused') { $query->where('is_active', false); } // default → no extra filter // Поиск по name и signal_identifier if ($search = $request->query('search')) { $query->where(function ($q) use ($search) { $q->where('name', 'ilike', "%{$search}%") ->orWhere('signal_identifier', 'ilike', "%{$search}%"); }); } // #7: фильтр по региону (subject code 1..89). Проект под фильтр попадает, если // его regions[] содержит код ИЛИ пуст (= вся РФ, имплицитно покрывает любой регион). $region = (int) $request->query('region', '0'); if ($region >= 1 && $region <= 89) { $query->where(function ($q) use ($region) { $q->whereRaw('regions @> ARRAY[?]::int[]', [$region]) ->orWhereRaw("regions = '{}'::int[]") ->orWhereNull('regions'); }); } // #7: фильтр по дню недели приёма (0=Пн..6=Вс — same bit-index, как в UI dayLabels). $day = $request->query('delivery_day'); if ($day !== null && $day !== '' && (int) $day >= 0 && (int) $day <= 6) { $bit = 1 << (int) $day; $query->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]); } // #7: сортировка. Whitelist + опциональный '-' для desc. Default = '-delivered_today' // (карточки с активной доставкой за сегодня видны сверху, как просил заказчик). $sortRaw = (string) $request->query('sort', '-delivered_today'); $desc = str_starts_with($sortRaw, '-'); $sortField = ltrim($sortRaw, '-'); $sortable = ['delivered_today', 'delivered_in_month', 'daily_limit_target', 'name', 'created_at']; if (! in_array($sortField, $sortable, true)) { $sortField = 'delivered_today'; $desc = true; } // #6: per_page до 200 (было 100). UI-селектор: 20/50/100/200. $perPage = min((int) $request->query('per_page', '20'), 200); $projects = $query ->orderBy($sortField, $desc ? 'desc' : 'asc') ->orderBy('id', 'desc') // стабильный tie-breaker для пагинации ->paginate($perPage); return response()->json([ 'data' => ProjectResource::collection($projects->items()), 'meta' => [ 'current_page' => $projects->currentPage(), 'per_page' => $projects->perPage(), 'total' => $projects->total(), ], ]); } /** POST /api/projects */ public function store(StoreProjectRequest $request): JsonResponse { $validated = $request->validated(); $tenant = $request->user()->tenant; // G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов. if (Project::where('tenant_id', $tenant->id)->count() === 0 && ! $this->requisites->isLightComplete($tenant)) { return response()->json(['error' => 'requisites_required'], 422); } unset($validated['force_save_blocked']); // больше не блокируем создание $project = $this->projects->create($tenant, $validated, launch: true); return response()->json([ 'data' => new ProjectResource($project->loadCount('supplierProjects')), 'launch' => [ 'launched' => $project->launch_deferred ? 0 : 1, 'deferred' => $project->launch_deferred ? 1 : 0, 'balance' => $project->gate_payload, ], ], 201); } /** PATCH /api/projects/{id} */ public function update(UpdateProjectRequest $request, int $id): JsonResponse { $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $validated = $request->validated(); $tenant = $request->user()->tenant; unset($validated['force_save_blocked']); $isLimitRaise = array_key_exists('daily_limit_target', $validated) && (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target && $project->is_active && $project->preflight_blocked_at === null; if ($isLimitRaise) { $newLimit = (int) $validated['daily_limit_target']; $tenantId = $tenant->id; return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse { Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail(); $gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]); if (! $gate->passes) { return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409); } $updated = $this->projects->update($project, $validated); return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); }); } $updated = $this->projects->update($project, $validated); return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); } /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { $project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 ->where('tenant_id', $request->user()->tenant_id) ->findOrFail($id); return response()->json(['data' => new ProjectResource($project)]); } /** DELETE /api/projects/{id} — hard delete (guard по сделкам: 422 если есть сделки) */ public function destroy(Request $request, int $id): JsonResponse { $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $this->projects->delete($project); return response()->json(null, 204); } /** POST /api/projects/{id}/sync — re-dispatch SyncSupplierProjectJob */ public function sync(Request $request, int $id): JsonResponse { $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $this->projects->triggerSync($project); return response()->json(['queued' => true, 'sync_status' => 'pending'], 202); } /** PATCH /api/projects/{id}/toggle-active — flip is_active flag */ public function toggleActive(Request $request, int $id): JsonResponse { $request->validate(['is_active' => ['required', 'boolean']]); $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $result = $this->projects->setActive($project, $request->boolean('is_active')); if ($result->activate_deferred ?? false) { return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409); } return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]); } /** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */ public function bulk(BulkProjectActionRequest $request): JsonResponse { $tenantId = $request->user()->tenant_id; $ids = $this->projects->resolveBulkScope( $tenantId, $request->validated('ids'), $request->validated('scope.filter'), ); if (count($ids) > ProjectService::BULK_MAX) { return response()->json([ 'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']], ], 422); } $payload = array_merge($request->validated(), ['ids' => $ids]); $result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload); return response()->json($result); } }