diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 8d96c089..525a87ce 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -12,6 +12,7 @@ use App\Models\Tenant; use App\Services\DaData\PartyLookup; use App\Services\Sales\SalesAttachmentService; use App\Support\InnValidator; +use App\Support\PhoneNormalizer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -239,7 +240,8 @@ class SalesProspectController extends Controller } /** - * Чистка контактных лиц: выбрасываем пустые телефоны и людей без имени и без телефонов. + * Чистка контактных лиц: выбрасываем пустые телефоны и людей без имени и без телефонов, + * телефоны нормализуем к «79XXXXXXXXX» и схлопываем дубли (PhoneNormalizer::cleanContactList). * Менеджер часто оставляет заготовленные пустые строки — в базу они попадать не должны. * * @param array $raw @@ -256,13 +258,8 @@ class SalesProspectController extends Controller $name = trim((string) ($item['name'] ?? '')); $position = trim((string) ($item['position'] ?? '')); - $phones = []; - foreach ((array) ($item['phones'] ?? []) as $phone) { - $phone = trim((string) $phone); - if ($phone !== '') { - $phones[] = $phone; - } - } + $rawPhones = array_map(static fn ($p) => (string) $p, (array) ($item['phones'] ?? [])); + $phones = PhoneNormalizer::cleanContactList($rawPhones); if ($name === '' && $phones === []) { continue; diff --git a/app/app/Support/PhoneNormalizer.php b/app/app/Support/PhoneNormalizer.php index 2612656b..8ada3f17 100644 --- a/app/app/Support/PhoneNormalizer.php +++ b/app/app/Support/PhoneNormalizer.php @@ -23,4 +23,35 @@ final class PhoneNormalizer return '+'.$digits; } + + /** + * Чистит список телефонов, введённых руками (контактные лица карточки): валидный + * российский номер → «79XXXXXXXXX» (без «+» — формат для Яндекс Аудиторий и единый + * с остальными телефонами), нераспознанное — ОСТАВЛЯЕМ как ввёл менеджер (не теряем). + * Дубли схлопываем с сохранением порядка первого вхождения. + * + * @param array $phones + * @return list + */ + public static function cleanContactList(array $phones): array + { + $out = []; + $seen = []; + foreach ($phones as $raw) { + $raw = trim((string) $raw); + if ($raw === '') { + continue; + } + $norm = self::normalize($raw); // "+7..." | null + $display = $norm !== null ? substr($norm, 1) : $raw; // "+7..." → "7..." ; иначе как есть + $key = $norm !== null ? $norm : (preg_replace('/\D+/', '', $raw) ?: $raw); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $out[] = $display; + } + + return $out; + } } diff --git a/app/tests/Feature/Sales/SalesProspectApiTest.php b/app/tests/Feature/Sales/SalesProspectApiTest.php index 52f0dc7d..9c5098ce 100644 --- a/app/tests/Feature/Sales/SalesProspectApiTest.php +++ b/app/tests/Feature/Sales/SalesProspectApiTest.php @@ -421,8 +421,8 @@ test('юрлицо и контактные лица сохраняются; phon ->assertJsonPath('prospect.legal_name', 'ООО «Улыбка Плюс»') ->assertJsonPath('prospect.contacts.0.name', 'Иванов Иван') ->assertJsonPath('prospect.contacts.0.position', 'директор') - ->assertJsonPath('prospect.contacts.1.phones.0', '+79130000003') - ->assertJsonPath('prospect.phone', '+79130000001'); + ->assertJsonPath('prospect.contacts.1.phones.0', '79130000003') + ->assertJsonPath('prospect.phone', '79130000001'); }); test('контакт без имени и пустые телефоны отбрасываются, карточка создаётся', function () { @@ -441,7 +441,22 @@ test('контакт без имени и пустые телефоны отбр $contacts = $res->json('prospect.contacts'); expect($contacts)->toHaveCount(1); expect($contacts[0]['name'])->toBe('Сидоров'); - expect($contacts[0]['phones'])->toBe(['+79130000009']); + expect($contacts[0]['phones'])->toBe(['79130000009']); +}); + +test('руками введённые телефоны контакта чистятся: формат 79…, дубли схлопываются, неизвестное не теряется', function () { + $mgr = pr_user('manager'); + + $res = $this->actingAs($mgr, 'sales')->postJson('/api/sales/prospects', [ + 'firm_name' => 'Чистка телефонов', + 'inn' => '5501234560', + 'contacts' => [ + ['name' => 'Захаров', 'position' => null, 'phones' => ['9135191264', '89135191264', '12345']], + ], + ]); + + $res->assertCreated(); + expect($res->json('prospect.contacts.0.phones'))->toBe(['79135191264', '12345']); }); test('подтяжка по ИНН: нашли → юрлицо и город из ДаData', function () { @@ -509,7 +524,7 @@ test('менеджер сохраняет контактные лица в св $res->assertOk() ->assertJsonPath('prospect.contacts.0.name', 'Захаров Александр') ->assertJsonPath('prospect.contacts.1.position', 'маркетолог') - ->assertJsonPath('prospect.contacts.1.phones.1', '+73812255739'); + ->assertJsonPath('prospect.contacts.1.phones.1', '73812255739'); expect(SalesProspect::find($p->id)->contacts)->toHaveCount(2); }); @@ -528,7 +543,7 @@ test('правка контактов: пустые люди и телефоны $res->assertOk()->assertJsonPath('prospect.stage', 'negotiation'); $fresh = SalesProspect::find($p->id); expect($fresh->contacts)->toHaveCount(1); - expect($fresh->contacts[0]['phones'])->toBe(['+79130000009']); + expect($fresh->contacts[0]['phones'])->toBe(['79130000009']); }); test('правка контактов: пустой список стирает всех (менеджер ошибся и убрал)', function () { diff --git a/app/tests/Unit/Support/PhoneNormalizerTest.php b/app/tests/Unit/Support/PhoneNormalizerTest.php index 014d52bd..dd5a38d1 100644 --- a/app/tests/Unit/Support/PhoneNormalizerTest.php +++ b/app/tests/Unit/Support/PhoneNormalizerTest.php @@ -15,3 +15,16 @@ it('normalizes russian phones to +7XXXXXXXXXX', function (string $input, ?string ['+1 202 555 0143', null], // 11 цифр, но начинается не с 7/8 ['', null], ]); + +it('чистит список контактных телефонов: нормализует к 79…, дубли схлопывает, неизвестное не теряет', function (array $input, array $expected) { + expect(PhoneNormalizer::cleanContactList($input))->toBe($expected); +})->with([ + 'десять цифр → 79' => [['9135191264'], ['79135191264']], + 'одиннадцать с 8 → 79' => [['89135191264'], ['79135191264']], + 'форматированный с +7 → 79' => [['+7 913 519-12-64'], ['79135191264']], + 'три формы одного номера → один' => [['9135191264', '89135191264', '+7 913 519-12-64'], ['79135191264']], + 'порядок первого вхождения сохраняется' => [['9135191264', '9991112233'], ['79135191264', '79991112233']], + 'нераспознанное — оставляем как ввёл' => [['12345'], ['12345']], + 'пустые строки отбрасываются' => [['', ' ', '9135191264'], ['79135191264']], + 'нераспознанное дедуп по цифрам' => [['12345', '12345'], ['12345']], +]); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 9aa03f77..7c92e8ac 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-19T05:39:20.467Z +Last updated: 2026-07-19T05:53:32.424Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,8 +112,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3540 | MsMpEng | 4.17ч | 0.0ч | -| 10836 | Code | 1.87ч | 0.0ч | +| 3540 | MsMpEng | 4.28ч | NaNч | +| 10836 | Code | 1.95ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.