diff --git a/app/app/Http/Controllers/Api/DashboardController.php b/app/app/Http/Controllers/Api/DashboardController.php index 9023daec..61de0ab3 100644 --- a/app/app/Http/Controllers/Api/DashboardController.php +++ b/app/app/Http/Controllers/Api/DashboardController.php @@ -74,7 +74,6 @@ class DashboardController extends Controller // --- active projects --- $activeProjects = DB::table('projects') ->where('tenant_id', $tenantId) - ->whereNull('archived_at') ->where('is_active', true) ->count(); $maxProjects = (int) (($tenant->limits['max_projects'] ?? 0)); diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 44db10c9..00421efd 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -52,16 +52,12 @@ class ProjectController extends Controller // Фильтр по статусу жизненного цикла $status = $request->query('status'); - if ($status === 'archived') { - $query->archived(); - } elseif ($status === 'active') { - $query->active()->where('is_active', true); + if ($status === 'active') { + $query->where('is_active', true); } elseif ($status === 'paused') { - $query->active()->where('is_active', false); - } else { - // По умолчанию: все не архивированные (active + paused) - $query->active(); + $query->where('is_active', false); } + // default → no extra filter // Поиск по name и signal_identifier if ($search = $request->query('search')) { diff --git a/app/app/Http/Requests/BulkProjectActionRequest.php b/app/app/Http/Requests/BulkProjectActionRequest.php index a9278712..cd1f1a74 100644 --- a/app/app/Http/Requests/BulkProjectActionRequest.php +++ b/app/app/Http/Requests/BulkProjectActionRequest.php @@ -28,7 +28,7 @@ class BulkProjectActionRequest extends FormRequest 'scope' => ['nullable', 'array'], 'scope.filter' => ['nullable', 'array'], 'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])], - 'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])], + 'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused'])], 'scope.filter.search' => ['nullable', 'string', 'max:255'], ]; diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php index 1d7fc46d..e8da77a0 100644 --- a/app/app/Http/Resources/ProjectResource.php +++ b/app/app/Http/Resources/ProjectResource.php @@ -13,9 +13,6 @@ class ProjectResource extends JsonResource { public function toArray(Request $request): array { - /** @var Project $project */ - $project = $this->resource; - return [ 'id' => $this->id, 'name' => $this->name, @@ -28,7 +25,6 @@ class ProjectResource extends JsonResource 'delivered_today' => $this->delivered_today, 'delivered_in_month' => $this->delivered_in_month, 'is_active' => $this->is_active, - 'archived_at' => $project->archived_at?->toIso8601String(), 'region_mask' => $this->region_mask, 'region_mode' => $this->region_mode, 'regions' => $this->regions, diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index a0599eec..e7d5cdfa 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -37,7 +37,7 @@ use Throwable; * (расписание перенесено 20:30 → 18:00, см. routes/console.php). * * Алгоритм (план 3 Task 5 → переработан: one-group-per-identifier): - * 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL). + * 1. Загрузить активные Лидерра-projects (is_active=true). * 2. Сгруппировать по (signal_type, identifier) — БЕЗ subject_code: * - identifier = buildUniqueKeyAgnostic() (site/call → signal_identifier; sms+keyword → sender+keyword; sms → sender). * - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3). @@ -86,7 +86,6 @@ class SyncSupplierProjectsJob implements ShouldQueue /** @var Collection $projects */ $projects = Project::on(self::DB_CONNECTION) ->where('is_active', true) - ->whereNull('archived_at') ->orderBy('id') ->get(); diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index 59f8685f..fa48b41e 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -40,8 +40,6 @@ class Project extends Model 'tag', 'type', 'is_active', - // Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active. - 'archived_at', 'daily_limit_target', 'effective_daily_limit_today', 'effective_limit_calculated_at', @@ -87,8 +85,6 @@ class Project extends Model 'sms_senders' => 'array', 'delivered_in_month' => 'integer', 'delivered_today' => 'integer', - // Plan 5 Task 1 (schema v8.20): soft archive. - 'archived_at' => 'datetime', ]; } @@ -151,33 +147,6 @@ class Project extends Model return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier); } - /** - * Не архивированные проекты (archived_at IS NULL). - * - * Внимание: scope не фильтрует is_active. Приостановленные (is_active=false) - * проекты сюда попадают — это разные lifecycle-состояния. Если нужны только - * «работающие» (не архив И не на паузе) — комбинируйте: - * ->active()->where('is_active', true). - * - * @param Builder $query - * @return Builder - */ - public function scopeActive(Builder $query): Builder - { - return $query->whereNull('archived_at'); - } - - /** - * Архивированные проекты (archived_at IS NOT NULL). - * - * @param Builder $query - * @return Builder - */ - public function scopeArchived(Builder $query): Builder - { - return $query->whereNotNull('archived_at'); - } - /** * Все связанные SupplierProject из eager-loaded BelongsTo отношений. * diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 687cde7d..d83354b9 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -21,7 +21,6 @@ class ProjectService $data['tenant_id'], $data['signal_type'], $data['delivered_today'], $data['delivered_in_month'], $data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'], - $data['archived_at'], ); if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) { @@ -106,9 +105,8 @@ class ProjectService } if (! empty($filter['status'])) { match ($filter['status']) { - 'active' => $query->where('is_active', true)->whereNull('archived_at'), - 'paused' => $query->where('is_active', false)->whereNull('archived_at'), - 'archived' => $query->whereNotNull('archived_at'), + 'active' => $query->where('is_active', true), + 'paused' => $query->where('is_active', false), default => null, }; } @@ -312,7 +310,7 @@ class ProjectService public function create(Tenant $tenant, array $data): Project { $limit = (int) ($tenant->limits['max_projects'] ?? 10); - $current = Project::where('tenant_id', $tenant->id)->active()->count(); + $current = Project::where('tenant_id', $tenant->id)->count(); if ($current >= $limit) { throw new HttpResponseException(response()->json([ 'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.", diff --git a/app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php b/app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php new file mode 100644 index 00000000..912b451f --- /dev/null +++ b/app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php @@ -0,0 +1,19 @@ +assertJsonPath('conversion.value', 25); }); -it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () { +it('active_projects считает is_active=true + limit из limits', function () { $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]); + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}") ->assertOk() ->assertJsonPath('active_projects.active', 2) diff --git a/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php index 45beafed..b810c24e 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php @@ -16,7 +16,7 @@ it('returns paginated list of active projects for current tenant', function () { $response->assertOk(); $response->assertJsonStructure([ 'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target', - 'delivered_today', 'is_active', 'archived_at', 'sync_status']], + 'delivered_today', 'is_active', 'sync_status']], 'meta' => ['current_page', 'per_page', 'total'], ]); expect($response->json('meta.total'))->toBe(3); @@ -45,23 +45,24 @@ it('isolates projects per tenant (RLS)', function () { expect($response->json('meta.total'))->toBe(2); }); -it('excludes archived projects by default', function () { +it('returns all projects by default (archive feature removed in v8.27)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); + Project::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->getJson('/api/projects'); - expect($response->json('meta.total'))->toBe(1); + expect($response->json('meta.total'))->toBe(2); }); -it('returns archived when status=archived requested', function () { +it('status=active returns only is_active=true projects', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); - $response = $this->actingAs($user)->getJson('/api/projects?status=archived'); + $response = $this->actingAs($user)->getJson('/api/projects?status=active'); expect($response->json('meta.total'))->toBe(1); }); @@ -140,19 +141,18 @@ it('search is case-insensitive for Cyrillic substrings', function () { expect($partial->json('meta.total'))->toBe(1); }); -it('show returns 200 for archived project (read access preserved)', function () { +it('show returns 200 for any project by id', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, - 'archived_at' => now(), 'signal_type' => 'site', - 'signal_identifier' => 'archived.ru', + 'signal_identifier' => 'myproject.ru', ]); $response = $this->actingAs($user)->getJson("/api/projects/{$project->id}"); $response->assertOk(); expect($response->json('data.id'))->toBe($project->id); - expect($response->json('data.archived_at'))->not->toBeNull(); + expect($response->json('data'))->not->toHaveKey('archived_at'); }); diff --git a/app/tests/Feature/Plan5/Schema/ArchivedAtTest.php b/app/tests/Feature/Plan5/Schema/ArchivedAtTest.php index 7d602884..d5ae2813 100644 --- a/app/tests/Feature/Plan5/Schema/ArchivedAtTest.php +++ b/app/tests/Feature/Plan5/Schema/ArchivedAtTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Models\Project; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Schema; @@ -11,15 +10,6 @@ use Illuminate\Support\Facades\Schema; // DatabaseTransactions — изоляция; also ensures DB connection is bootstrapped. uses(DatabaseTransactions::class); -it('projects table has archived_at column nullable timestamp', function () { - expect(Schema::hasColumn('projects', 'archived_at'))->toBeTrue(); - $type = Schema::getColumnType('projects', 'archived_at'); - // PostgreSQL TIMESTAMPTZ → Doctrine/Laravel reports 'timestamptz' (not 'timestamp'). - expect($type)->toBe('timestamptz'); -}); - -it('Project model has archived_at in fillable and casts it to datetime', function () { - $project = new Project; - expect(in_array('archived_at', $project->getFillable(), true))->toBeTrue(); - expect($project->getCasts()['archived_at'] ?? null)->toBe('datetime'); +it('projects table does NOT have archived_at column (feature removed in v8.27)', function () { + expect(Schema::hasColumn('projects', 'archived_at'))->toBeFalse(); }); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 996ad252..21fd42ce 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -57,7 +57,6 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'persubject.example.com', 'daily_limit_target' => 9, @@ -116,7 +115,6 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'rf-pool.example.com', 'daily_limit_target' => 6, @@ -167,7 +165,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 10, @@ -178,7 +175,6 @@ test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 20, @@ -229,7 +225,6 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79001234567'], @@ -271,7 +266,6 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function ( Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79009876543'], @@ -314,7 +308,6 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'idempotent.example.com', 'daily_limit_target' => 9, @@ -375,7 +368,6 @@ test('respects time budget by stopping at 20:55 МСК', function (): void { Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'time-budget.example.com', 'daily_limit_target' => 9, @@ -397,7 +389,6 @@ test('sticky auth error throws and sends critical alert email', function (): voi Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'auth-fail.example.com', 'daily_limit_target' => 9, @@ -425,7 +416,6 @@ test('aborts after 50 consecutive transient failures and sends alert', function Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => "host{$i}.abort.com", 'daily_limit_target' => 9, @@ -449,7 +439,6 @@ test('writes supplier_sync_log row for each successful action', function (): voi Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, - 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'audit-log.example.com', 'daily_limit_target' => 9, diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index aea0e57f..0022cecd 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -6,6 +6,10 @@ **История записей:** +## v8.27 — 2026-05-21 — DROP COLUMN projects.archived_at + +- DROP COLUMN `projects.archived_at` — фича «архив» полностью убрана и заменена настоящим удалением с защитой по сделкам (`ProjectService::delete()`). Миграция `2026_05_21_000000_drop_projects_archived_at.php`. + ## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт) `supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»), diff --git a/db/schema.sql b/db/schema.sql index 3109ddd2..57efcdfb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.26 (20.05.2026 — project-migration-redesign Plans 1-3: supplier_projects.subject_code (per-субъект экспорт) + project_supplier_links (M:N pivot projects↔supplier_projects) + deals.subject_code + CHECK chk_deals_subject_code + seed system_settings.supplier_export_mode) +-- Версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) -- Метрики: 65 базовые таблицы (63 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 123 индекса / 40 RLS-политик / 5 функций / 13 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) @@ -840,7 +840,6 @@ CREATE TABLE projects ( CHECK (ttfr_target_minutes BETWEEN 1 AND 1440), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, - archived_at TIMESTAMPTZ NULL, -- v8.20 (Plan 5): soft archive flow (отличие от is_active=false который = pause) UNIQUE (tenant_id, name), CONSTRAINT chk_projects_daily_limit_positive CHECK (daily_limit_target > 0),