with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers ->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); } $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); unset($validated['force_save_blocked']); // Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные. $existingLimit = (int) Project::where('tenant_id', $tenant->id) ->where('is_active', true) ->whereNull('preflight_blocked_at') ->sum('daily_limit_target'); $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; $preflight = $this->runPreflight($tenant, $wouldBeRequired); if (! $preflight['passes'] && ! $forceSaveBlocked) { return response()->json([ 'error' => 'balance_insufficient', 'current_balance_rub' => (string) $tenant->balance_rub, 'current_capacity_leads' => $preflight['capacity_leads'], 'would_be_required_leads' => $wouldBeRequired, 'deficit_leads' => $preflight['deficit_leads'], ], 409); } if (! $preflight['passes'] && $forceSaveBlocked) { $validated['preflight_blocked_at'] = now(); } $project = $this->projects->create($tenant, $validated); return response()->json(['data' => new ProjectResource($project)], 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; $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); unset($validated['force_save_blocked']); // Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО // проекта + лимиты остальных активных не-blocked. if (array_key_exists('daily_limit_target', $validated)) { $existingLimit = (int) Project::where('tenant_id', $tenant->id) ->where('id', '!=', $project->id) ->where('is_active', true) ->whereNull('preflight_blocked_at') ->sum('daily_limit_target'); $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; $preflight = $this->runPreflight($tenant, $wouldBeRequired); if (! $preflight['passes'] && ! $forceSaveBlocked) { return response()->json([ 'error' => 'balance_insufficient', 'current_balance_rub' => (string) $tenant->balance_rub, 'current_capacity_leads' => $preflight['capacity_leads'], 'would_be_required_leads' => $wouldBeRequired, 'deficit_leads' => $preflight['deficit_leads'], ], 409); } if (! $preflight['passes'] && $forceSaveBlocked) { $validated['preflight_blocked_at'] = now(); } } $updated = $this->projects->update($project, $validated); return response()->json(['data' => new ProjectResource($updated)]); } /** * @return array{passes: bool, capacity_leads: int, deficit_leads: int} */ private function runPreflight(Tenant $tenant, int $requiredLeads): array { $tiers = PricingTier::query()->where('is_active', true)->get(); // Safe fallback: без активных pricing_tiers биллинг не настроен — // преfflight не имеет смысла, пропускаем (legacy-окружения / тесты). if ($tiers->isEmpty()) { return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; } $result = (new BalancePreflightService)->evaluate( balanceRub: (string) $tenant->balance_rub, deliveredInMonth: (int) $tenant->delivered_in_month, requiredLeads: $requiredLeads, tiers: $tiers, ); return [ 'passes' => $result->passes, 'capacity_leads' => $result->capacityLeads, 'deficit_leads' => $result->deficitLeads, ]; } /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { $project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid 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); // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11). // paused_at — anchor для SupplierSnapshotGuard grace-расчёта. $newActive = $request->boolean('is_active'); $project->update([ 'is_active' => $newActive, 'paused_at' => $newActive ? null : now(), ]); // #10: pause/resume must reach the supplier. The job's group recompute pushes // status=paused when no active project of the group remains (resume → active). SyncSupplierProjectJob::dispatch($project->id); return response()->json(['data' => new ProjectResource($project->fresh())]); } /** 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); } }