diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 7313d037..8df72c51 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\BulkProjectActionRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; @@ -109,4 +110,44 @@ class ProjectController extends Controller return response()->json(['data' => new ProjectResource($project)]); } + + /** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */ + public function destroy(Request $request, int $id): JsonResponse + { + $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); + $this->projects->archive($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); + $project->update(['is_active' => $request->boolean('is_active')]); + + return response()->json(['data' => new ProjectResource($project->fresh())]); + } + + /** POST /api/projects/bulk — batch pause/resume/archive */ + public function bulk(BulkProjectActionRequest $request): JsonResponse + { + $updated = $this->projects->bulkAction( + $request->user()->tenant_id, + $request->validated('action'), + $request->validated('ids'), + ); + + return response()->json(['updated' => $updated]); + } } diff --git a/app/app/Http/Requests/BulkProjectActionRequest.php b/app/app/Http/Requests/BulkProjectActionRequest.php new file mode 100644 index 00000000..0204c3b2 --- /dev/null +++ b/app/app/Http/Requests/BulkProjectActionRequest.php @@ -0,0 +1,25 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'action' => ['required', Rule::in(['pause', 'resume', 'archive'])], + 'ids' => ['required', 'array', 'min:1', 'max:100'], + 'ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 4714790f..14a4220e 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -42,6 +42,37 @@ class ProjectService return $project->fresh(); } + public function archive(Project $project): void + { + if ($project->archived_at !== null) { + throw new HttpResponseException(response()->json([ + 'message' => 'Project уже архивирован.', + ], 409)); + } + $project->update([ + 'is_active' => false, + 'archived_at' => now(), + ]); + } + + public function triggerSync(Project $project): void + { + SyncSupplierProjectJob::dispatch($project->id); + } + + public function bulkAction(int $tenantId, string $action, array $ids): int + { + $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 $query->update($update); + } + public function create(Tenant $tenant, array $data): Project { $limit = (int) ($tenant->limits['max_projects'] ?? 10); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 1caf90d3..e8fd44f5 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -102,6 +102,18 @@ parameters: count: 1 path: app/Services/NotificationService.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#' + identifier: property.notFound + count: 1 + path: app/Services/Project/ProjectService.php + + - + message: '#^Match expression does not handle remaining value\: string$#' + identifier: match.unhandled + count: 1 + path: app/Services/Project/ProjectService.php + - message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -864,6 +876,18 @@ parameters: count: 6 path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 9 + path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index d536c4ac..1766bba6 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -151,8 +151,14 @@ Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(function () { Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index'); Route::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store'); + // /bulk MUST be declared before /{id} parameterized routes so the literal + // segment matches before the regex placeholder is even considered. + Route::post('/bulk', 'App\Http\Controllers\Api\ProjectController@bulk')->name('projects.bulk'); Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+'); Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+'); + Route::delete('/{id}', 'App\Http\Controllers\Api\ProjectController@destroy')->name('projects.destroy')->where('id', '[0-9]+'); + Route::post('/{id}/sync', 'App\Http\Controllers\Api\ProjectController@sync')->name('projects.sync')->where('id', '[0-9]+'); + Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); }); // Receive endpoint для входящих webhook'ов (narrative §5.5). diff --git a/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php b/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php new file mode 100644 index 00000000..2e460428 --- /dev/null +++ b/app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php @@ -0,0 +1,112 @@ + Queue::fake()); + +it('destroy archives project (sets archived_at, is_active=false)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + + $this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertNoContent(); + + $project->refresh(); + expect($project->is_active)->toBeFalse(); + expect($project->archived_at)->not->toBeNull(); +}); + +it('destroy returns 409 if already archived', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); + + $this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(409); +}); + +it('sync re-dispatches SyncSupplierProjectJob', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson("/api/projects/{$project->id}/sync") + ->assertStatus(202) + ->assertJsonPath('queued', true); + + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('toggle-active flips is_active flag', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", ['is_active' => false]) + ->assertOk(); + + expect($project->fresh()->is_active)->toBeFalse(); +}); + +it('bulk pause sets is_active=false on multiple', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $p1 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $p2 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + + $this->actingAs($user)->postJson('/api/projects/bulk', [ + 'action' => 'pause', 'ids' => [$p1->id, $p2->id], + ])->assertOk()->assertJsonPath('updated', 2); + + expect($p1->fresh()->is_active)->toBeFalse(); + expect($p2->fresh()->is_active)->toBeFalse(); +}); + +it('bulk filters out cross-tenant ids silently', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $userA = User::factory()->create(['tenant_id' => $tenantA->id]); + $pA = Project::factory()->create(['tenant_id' => $tenantA->id, 'is_active' => true]); + $pB = Project::factory()->create(['tenant_id' => $tenantB->id, 'is_active' => true]); + + $this->actingAs($userA)->postJson('/api/projects/bulk', [ + 'action' => 'pause', 'ids' => [$pA->id, $pB->id], + ])->assertOk()->assertJsonPath('updated', 1); + + expect($pB->fresh()->is_active)->toBeTrue(); +}); + +it('bulk archive sets archived_at on multiple', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $p1 = Project::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson('/api/projects/bulk', [ + 'action' => 'archive', 'ids' => [$p1->id], + ])->assertOk(); + + expect($p1->fresh()->archived_at)->not->toBeNull(); +}); + +it('bulk rejects > 100 ids', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson('/api/projects/bulk', [ + 'action' => 'pause', 'ids' => range(1, 101), + ])->assertStatus(422); +}); + +it('bulk rejects unknown action', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->postJson('/api/projects/bulk', [ + 'action' => 'destroy_all', 'ids' => [1], + ])->assertStatus(422); +});