diff --git a/app/app/Casts/PostgresIntArray.php b/app/app/Casts/PostgresIntArray.php new file mode 100644 index 00000000..c4fa0e49 --- /dev/null +++ b/app/app/Casts/PostgresIntArray.php @@ -0,0 +1,82 @@ +, list|null> + */ +class PostgresIntArray implements CastsAttributes +{ + /** + * @param array $attributes + * @return list + */ + public function get(Model $model, string $key, mixed $value, array $attributes): array + { + if ($value === null || $value === '' || $value === '{}') { + return []; + } + + // PG returns literal like "{1,2,3}". + if (is_string($value)) { + $trimmed = trim($value, '{}'); + + if ($trimmed === '') { + return []; + } + + return array_map('intval', explode(',', $trimmed)); + } + + // Defensive: if driver already gave array. + if (is_array($value)) { + return array_values(array_map('intval', $value)); + } + + return []; + } + + /** + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + // Defensive: interface phpdoc says list|null, but $value is mixed at PHP level; + // protect against runtime misuse (e.g., string passed mistakenly). + // @phpstan-ignore function.alreadyNarrowedType + if (! is_array($value)) { + throw new \InvalidArgumentException( + "PostgresIntArray cast expects array for key '{$key}', got ".gettype($value) + ); + } + + if ($value === []) { + return '{}'; + } + + $ints = array_map('intval', $value); + + return '{'.implode(',', $ints).'}'; + } +} diff --git a/app/app/Http/Requests/StoreProjectRequest.php b/app/app/Http/Requests/StoreProjectRequest.php index 3fa6fe5b..632297bf 100644 --- a/app/app/Http/Requests/StoreProjectRequest.php +++ b/app/app/Http/Requests/StoreProjectRequest.php @@ -22,8 +22,11 @@ class StoreProjectRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])], 'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'], - 'region_mask' => ['required', 'integer', 'min:0'], - 'region_mode' => ['required', Rule::in(['include', 'exclude'])], + // Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне. + // Empty array = "вся РФ" (паритет с legacy region_mask=255 + region_mode='include'). + // present = поле должно быть в payload (даже если []), enforces explicit choice. + 'regions' => ['present', 'array'], + 'regions.*' => ['integer', 'between:1,89'], 'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'], ]; diff --git a/app/app/Http/Requests/UpdateProjectRequest.php b/app/app/Http/Requests/UpdateProjectRequest.php index 1401e289..0d230557 100644 --- a/app/app/Http/Requests/UpdateProjectRequest.php +++ b/app/app/Http/Requests/UpdateProjectRequest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class UpdateProjectRequest extends FormRequest { @@ -20,8 +19,10 @@ class UpdateProjectRequest extends FormRequest return [ 'name' => ['sometimes', 'string', 'max:255'], 'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'], - 'region_mask' => ['sometimes', 'integer', 'min:0'], - 'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])], + // Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне. + // sometimes = поле omit-able (preserves prior DB value), массив + each 1..89. + 'regions' => ['sometimes', 'array'], + 'regions.*' => ['integer', 'between:1,89'], 'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'], 'sms_senders' => ['sometimes', 'array', 'min:1'], 'sms_senders.*' => ['string', 'max:11'], diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index a3f62469..6ea569a8 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Casts\PostgresIntArray; use Carbon\CarbonInterface; use Database\Factories\ProjectFactory; use Illuminate\Database\Eloquent\Builder; @@ -45,6 +46,9 @@ class Project extends Model 'effective_limit_calculated_at', 'region_mask', 'region_mode', + // Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts). + // Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup). + 'regions', 'delivery_days_mask', 'assignment_strategy', 'ttfr_target_minutes', @@ -69,6 +73,10 @@ class Project extends Model 'daily_limit_target' => 'integer', 'effective_daily_limit_today' => 'integer', 'region_mask' => 'integer', + // Plan 6: Subject-level regions array (89 codes). Используется кастомный + // PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`, + // что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`). + 'regions' => PostgresIntArray::class, 'delivery_days_mask' => 'integer', 'ttfr_target_minutes' => 'integer', 'effective_limit_calculated_at' => 'datetime', diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 4903852f..fd5f39f6 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -191,6 +191,11 @@ class ProjectService $data['tenant_id'] = $tenant->id; $data['is_active'] = true; + $data['regions'] = $data['regions'] ?? []; + // Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для + // PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей. + $data['region_mask'] = 255; + $data['region_mode'] = 'include'; $project = Project::create($data); SyncSupplierProjectJob::dispatch($project->id); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index c5282685..fe092b63 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1,5 +1,19 @@ parameters: ignoreErrors: + # Plan 6 (v8.20): Project::$regions INT[] cast via PostgresIntArray; ide-helper + # regen pending (will resolve after next `php artisan ide-helper:models -W`). + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$regions\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$regions\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php + - message: '#^Expression on left side of \?\? is not nullable\.$#' identifier: nullCoalesce.expr @@ -903,13 +917,13 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 9 + count: 12 path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 6 + count: 8 path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php - diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index dff0b274..5a917661 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -75,7 +75,7 @@ it('schema.sql v8.19 has correct metrics — 62 base tables, 117 indexes, 39 RLS expect($baseTables)->toBe(62); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(117); + expect($createIndexes)->toBe(118); // Plan 6 (v8.20): +1 GIN idx_projects_regions $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); expect($createPolicies)->toBe(39); diff --git a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php index 5ef14afd..9baf9db9 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php @@ -19,8 +19,7 @@ it('creates a site project with valid payload', function () { 'signal_type' => 'site', 'signal_identifier' => 'okna-spb.ru', 'daily_limit_target' => 50, - 'region_mask' => 0, - 'region_mode' => 'include', + 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -36,7 +35,7 @@ it('rejects invalid site domain', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain', - 'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 50, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -50,7 +49,7 @@ it('creates a call project with valid 11-digit phone', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567', - 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 30, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -63,7 +62,7 @@ it('rejects call signal_identifier not starting with 7', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567', - 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 30, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -77,7 +76,7 @@ it('creates sms project with senders + keyword', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'Ипотека', 'signal_type' => 'sms', 'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека', - 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 100, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -93,7 +92,7 @@ it('rejects sms project without sms_senders', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'X', 'signal_type' => 'sms', - 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 100, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -108,7 +107,7 @@ it('rejects when tenant exceeds max_projects limit', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru', - 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 10, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -123,7 +122,7 @@ it('forces tenant_id from auth user (not from payload)', function () { $this->actingAs($userA)->postJson('/api/projects', [ 'tenant_id' => $tenantB->id, // попытка инъекции 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru', - 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 10, 'regions' => [], 'delivery_days_mask' => 127, ]); @@ -137,10 +136,66 @@ it('rejects site domain with consecutive dots', function () { $response = $this->actingAs($user)->postJson('/api/projects', [ 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'okna..spb.ru', - 'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include', + 'daily_limit_target' => 50, 'regions' => [], 'delivery_days_mask' => 127, ]); $response->assertStatus(422); $response->assertJsonValidationErrors(['signal_identifier']); }); + +// Plan 6 — subject-level regions[] support. + +it('creates project with subject-level regions array', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Regions Test Project', + 'signal_type' => 'site', + 'signal_identifier' => 'regions-test.example', + 'daily_limit_target' => 50, + 'delivery_days_mask' => 127, + 'regions' => [82, 83], // Москва + СПб + ]); + + $response->assertStatus(201); + $created = Project::where('name', 'Regions Test Project')->firstOrFail(); + expect($created->regions)->toBe([82, 83]); +}); + +it('dual-writes region_mask=255 + region_mode=include for backward-compat', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Dual Write Test', + 'signal_type' => 'site', + 'signal_identifier' => 'dualwrite.example', + 'daily_limit_target' => 50, + 'delivery_days_mask' => 127, + 'regions' => [77], + ]); + + $response->assertStatus(201); + $created = Project::where('name', 'Dual Write Test')->firstOrFail(); + expect($created->region_mask)->toBe(255); + expect($created->region_mode)->toBe('include'); +}); + +it('rejects regions code out of 1..89 range with 422', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Invalid Code Test', + 'signal_type' => 'site', + 'signal_identifier' => 'invalid.example', + 'daily_limit_target' => 50, + 'delivery_days_mask' => 127, + 'regions' => [90, 100], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['regions.0', 'regions.1']); +}); diff --git a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php index 96b1fb52..2398ef7c 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php @@ -78,14 +78,49 @@ it('cross-tenant update returns 404', function () { ])->assertStatus(404); }); -it('updates region_mask and delivery_days_mask', function () { +it('updates delivery_days_mask (region_mask now read-only — see regions[] tests below)', function () { + // Plan 6: region_mask/region_mode больше не клиент-controllable через UpdateProjectRequest + // (validation rules удалены, ProjectService::create dual-writes 255/include). + // Источник истины для региональной фильтрации — projects.regions INT[] (Plan 6). + // Этот тест адаптирован: проверяет, что delivery_days_mask остаётся writeable через PATCH. $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'region_mask' => 78, 'region_mode' => 'exclude', 'delivery_days_mask' => 31, + 'delivery_days_mask' => 31, ])->assertOk(); - expect($project->fresh()->region_mask)->toBe(78); + expect($project->fresh()->delivery_days_mask)->toBe(31); +}); + +// Plan 6 — subject-level regions[] support. + +it('updates regions array via PATCH', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => []]); + + $response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'regions' => [82], + ]); + + $response->assertStatus(200); + expect($project->fresh()->regions)->toBe([82]); +}); + +it('preserves regions when PATCH omits the field (sometimes rule)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'regions' => [82, 83], + ]); + + $response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'name' => 'Renamed Project', + ]); + + $response->assertStatus(200); + expect($project->fresh()->regions)->toBe([82, 83]); });