diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index b11c15ce..be2c10a1 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -139,7 +139,7 @@ class ProjectController extends Controller return response()->json(['data' => new ProjectResource($project->fresh())]); } - /** POST /api/projects/bulk — batch pause/resume/archive */ + /** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */ public function bulk(BulkProjectActionRequest $request): JsonResponse { $tenantId = $request->user()->tenant_id; @@ -168,8 +168,16 @@ class ProjectController extends Controller $ids = $request->validated('ids') ?? []; } - $updated = $this->projects->bulkAction($tenantId, $action, $ids); + if (count($ids) > 500) { + return response()->json([ + 'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']], + ], 422); + } - return response()->json(['updated' => $updated]); + $payload = array_merge($request->validated(), ['ids' => $ids]); + + $result = $this->projects->bulkAction($tenantId, $action, $payload); + + return response()->json($result); } } diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 14a4220e..9249ea7e 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -60,17 +60,53 @@ class ProjectService SyncSupplierProjectJob::dispatch($project->id); } - public function bulkAction(int $tenantId, string $action, array $ids): int + public function bulkAction(int $tenantId, string $action, array $payload): array { + $ids = $payload['ids'] ?? []; + if (empty($ids)) { + return ['updated' => 0, 'skipped' => [], 'warnings' => []]; + } + $query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids); - $update = match ($action) { - 'pause' => ['is_active' => false], - 'resume' => ['is_active' => true], - 'archive' => ['is_active' => false, 'archived_at' => now()], + return match ($action) { + 'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]), + 'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]), + 'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]), + 'update_regions' => $this->bulkUpdateRegions($query, $payload), + 'update_days' => $this->bulkUpdateDays($query, $payload), + 'update_limit' => $this->bulkUpdateLimit($query, $payload), }; + } - return $query->update($update); + private function bulkSimpleUpdate($query, array $update): array + { + $updated = $query->update($update); + + return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; + } + + private function bulkUpdateRegions($query, array $payload): array + { + $add = (int) ($payload['add'] ?? 0); + $remove = (int) ($payload['remove'] ?? 0); + + // region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255) + $updated = $query->update([ + 'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"), + ]); + + return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; + } + + private function bulkUpdateDays($query, array $payload): array + { + return ['updated' => 0, 'skipped' => [], 'warnings' => []]; + } + + private function bulkUpdateLimit($query, array $payload): array + { + return ['updated' => 0, 'skipped' => [], 'warnings' => []]; } public function create(Tenant $tenant, array $data): Project diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 75589277..6b805c62 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1257,5 +1257,5 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 5 + count: 6 path: tests/Feature/Api/ProjectBulkActionsTest.php diff --git a/app/tests/Feature/Api/ProjectBulkActionsTest.php b/app/tests/Feature/Api/ProjectBulkActionsTest.php index 6b35df9e..12fbc3e3 100644 --- a/app/tests/Feature/Api/ProjectBulkActionsTest.php +++ b/app/tests/Feature/Api/ProjectBulkActionsTest.php @@ -20,7 +20,7 @@ it('accepts update_regions action with add/remove bitmask', function () { ]) ->assertOk() ->assertJsonStructure(['updated', 'skipped', 'warnings']); -})->todo('Service handler implemented in Task 2'); +}); it('rejects unknown action', function () { $user = User::factory()->create(); @@ -68,3 +68,23 @@ it('accepts empty scope.filter as valid scope (all projects)', function () { ]) ->assertOk(); }); + +it('applies update_regions add and remove correctly', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create(); + $p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2 + $p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4 + + $this->actingAs($user) + ->postJson('/api/projects/bulk', [ + 'action' => 'update_regions', + 'ids' => [$p1->id, $p2->id], + 'add' => 16, // 16 = Приволжский + 'remove' => 1, // 1 = Центральный + ]) + ->assertOk() + ->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]); + + expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18 + expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20 +});