diff --git a/app/app/Http/Requests/StoreProjectRequest.php b/app/app/Http/Requests/StoreProjectRequest.php index 92f6ba01..3d2a5599 100644 --- a/app/app/Http/Requests/StoreProjectRequest.php +++ b/app/app/Http/Requests/StoreProjectRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests; +use App\Support\PhoneNormalizer; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -14,6 +15,33 @@ class StoreProjectRequest extends FormRequest return $this->user() !== null; } + /** + * Косяк 02: для типа «call» приводим введённый номер к каноничному виду + * 7XXXXXXXXXX тем же нормализатором, что и реквизиты (PhoneNormalizer). + * Источник проекта хранится без ведущего «+» — раздача лидов матчит + * signal_identifier как есть (LeadRouter), поэтому «+» срезаем. + * Невалидный мусор оставляем как ввели — финальная regex даст ошибку. + */ + protected function prepareForValidation(): void + { + if ($this->input('signal_type') === 'call' && $this->filled('signal_identifier')) { + $normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier')); + if ($normalized !== null) { + $this->merge(['signal_identifier' => ltrim($normalized, '+')]); + } + } + } + + /** @return array */ + public function messages(): array + { + return match ($this->input('signal_type')) { + 'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'], + 'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'], + default => [], + }; + } + public function rules(): array { $signalType = $this->input('signal_type'); diff --git a/app/app/Http/Requests/UpdateProjectRequest.php b/app/app/Http/Requests/UpdateProjectRequest.php index dc094347..1337a64c 100644 --- a/app/app/Http/Requests/UpdateProjectRequest.php +++ b/app/app/Http/Requests/UpdateProjectRequest.php @@ -5,15 +5,59 @@ declare(strict_types=1); namespace App\Http\Requests; use App\Models\Project; +use App\Support\PhoneNormalizer; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { + private ?Project $resolvedProject = null; + + private bool $projectResolved = false; + public function authorize(): bool { return $this->user() !== null; } + /** + * Косяк 02: при редактировании call-проекта нормализуем введённый номер + * к 7XXXXXXXXXX (тот же PhoneNormalizer, что и реквизиты; «+» срезаем — + * раздача матчит без него). Тип signal_type immutable — берём из проекта. + */ + protected function prepareForValidation(): void + { + if (! $this->filled('signal_identifier')) { + return; + } + if ($this->resolveProject()?->signal_type === 'call') { + $normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier')); + if ($normalized !== null) { + $this->merge(['signal_identifier' => ltrim($normalized, '+')]); + } + } + } + + /** @return array */ + public function messages(): array + { + return match ($this->resolveProject()?->signal_type) { + 'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'], + 'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'], + default => [], + }; + } + + private function resolveProject(): ?Project + { + if (! $this->projectResolved) { + $projectId = $this->route('id'); + $this->resolvedProject = $projectId !== null ? Project::find($projectId) : null; + $this->projectResolved = true; + } + + return $this->resolvedProject; + } + public function rules(): array { // signal_type immutable: не валидируется в правилах, controller игнорирует поле @@ -36,17 +80,14 @@ class UpdateProjectRequest extends FormRequest // 18.05.2026 UX: редактирование источника (signal_identifier) для site/call. // Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}). // signal_type immutable — берём из текущего проекта по route id. - $projectId = $this->route('id'); - if ($projectId !== null) { - $project = Project::find($projectId); - if ($project !== null) { - if ($project->signal_type === 'site') { - $rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i']; - } elseif ($project->signal_type === 'call') { - $rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/']; - } - // sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше) + $project = $this->resolveProject(); + if ($project !== null) { + if ($project->signal_type === 'site') { + $rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i']; + } elseif ($project->signal_type === 'call') { + $rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/']; } + // sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше) } return $rules; diff --git a/app/resources/js/components/projects/ProjectDetailsDrawer.vue b/app/resources/js/components/projects/ProjectDetailsDrawer.vue index 35eeaf30..33f7c7bb 100644 --- a/app/resources/js/components/projects/ProjectDetailsDrawer.vue +++ b/app/resources/js/components/projects/ProjectDetailsDrawer.vue @@ -211,6 +211,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; placeholder="79161234567" :disabled="sourceLocked" /> +
Можно с +7, 8, скобками и пробелами — приведём к виду 79161234567
{{ errors.signal_identifier[0] }}
@@ -445,6 +446,11 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; font-size: 12px; margin-top: 4px; } +.pdd-hint { + color: #6b6f72; + font-size: 12px; + margin-top: 4px; +} .pdd-lock-hint { margin-top: 6px; font-size: 12.5px; diff --git a/app/resources/js/views/projects/NewProjectDialog.vue b/app/resources/js/views/projects/NewProjectDialog.vue index e4d0de31..cdf3bf48 100644 --- a/app/resources/js/views/projects/NewProjectDialog.vue +++ b/app/resources/js/views/projects/NewProjectDialog.vue @@ -50,7 +50,8 @@ v-model="form.signal_identifier" label="Номер конкурента" placeholder="79161234567" - hint="Формат: 11 цифр, начинаются с 7" + hint="Можно вводить с +7, 8, скобками и пробелами — приведём к виду 79161234567" + persistent-hint :readonly="mode === 'edit'" class="ld-input-quiet" :error-messages="errors.signal_identifier" diff --git a/app/tests/Feature/Project/ProjectPhoneNormalizationTest.php b/app/tests/Feature/Project/ProjectPhoneNormalizationTest.php new file mode 100644 index 00000000..4a45739c --- /dev/null +++ b/app/tests/Feature/Project/ProjectPhoneNormalizationTest.php @@ -0,0 +1,110 @@ +create([ + 'tier_no' => 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 5000, // 50₽/лид + 'is_active' => true, + 'effective_from' => now(), + ]); + // Баланс заведомо большой — преflight всегда проходит, тест про нормализацию. + $this->tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '100000.00']); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); +}); + +function createCallProject(string $rawPhone, string $name = 'Звонок'): TestResponse +{ + return test()->actingAs(test()->user)->postJson('/api/projects', [ + 'name' => $name, + 'signal_type' => 'call', + 'signal_identifier' => $rawPhone, + 'daily_limit_target' => 5, + 'regions' => [], + 'delivery_days_mask' => 127, + ]); +} + +it('normalizes +7 (916) 123-45-67 to 79161234567 on create', function () { + createCallProject('+7 (916) 123-45-67')->assertCreated(); + expect(Project::where('tenant_id', $this->tenant->id)->value('signal_identifier')) + ->toBe('79161234567'); +}); + +it('normalizes leading 8 to 7 on create', function () { + createCallProject('8 916 123 45 67')->assertCreated(); + expect(Project::where('tenant_id', $this->tenant->id)->value('signal_identifier')) + ->toBe('79161234567'); +}); + +it('normalizes hyphenated 7-916-123-45-67 on create', function () { + createCallProject('7-916-123-45-67')->assertCreated(); + expect(Project::where('tenant_id', $this->tenant->id)->value('signal_identifier')) + ->toBe('79161234567'); +}); + +it('keeps already canonical 79161234567 unchanged (idempotent)', function () { + createCallProject('79161234567')->assertCreated(); + expect(Project::where('tenant_id', $this->tenant->id)->value('signal_identifier')) + ->toBe('79161234567'); +}); + +it('never stores a leading plus (routing matches without +)', function () { + createCallProject('+79161234567')->assertCreated(); + $stored = Project::where('tenant_id', $this->tenant->id)->value('signal_identifier'); + expect($stored)->toBe('79161234567') + ->and($stored)->not->toContain('+'); +}); + +it('rejects real garbage with a helpful message naming the on-screen field', function () { + $response = createCallProject('12345'); + $response->assertStatus(422); + $msg = $response->json('errors.signal_identifier.0'); + expect($msg)->toContain('79161234567') // показывает пример формата + ->and($msg)->not->toContain('Источник'); // не всплывает внутреннее имя поля +}); + +it('does NOT normalize a site domain (only call is a phone)', function () { + test()->actingAs($this->user)->postJson('/api/projects', [ + 'name' => 'Сайт', + 'signal_type' => 'site', + 'signal_identifier' => 'my-site.ru', + 'daily_limit_target' => 5, + 'regions' => [], + 'delivery_days_mask' => 127, + ])->assertCreated(); + expect(Project::where('tenant_id', $this->tenant->id)->value('signal_identifier')) + ->toBe('my-site.ru'); +}); + +it('normalizes phone on update of a call project', function () { + $project = Project::factory()->for($this->tenant)->create([ + 'signal_type' => 'call', + 'signal_identifier' => '79990001122', + 'is_active' => true, + 'daily_limit_target' => 5, + ]); + + test()->actingAs($this->user)->patchJson("/api/projects/{$project->id}", [ + 'signal_identifier' => '+7 (916) 123-45-67', + ])->assertOk(); + + expect($project->fresh()->signal_identifier)->toBe('79161234567'); +});