From 3ab8b2cbffd005cb601dc89663bf1c037120c951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 19 Jul 2026 08:57:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales):=20=D1=80=D1=83=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=8F=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?(=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=2079=E2=80=A6,=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=20=D0=B4=D0=BE=D0=BB=D0=BE=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../Api/Sales/SalesProspectController.php | 13 +++----- app/app/Support/PhoneNormalizer.php | 31 +++++++++++++++++++ .../Feature/Sales/SalesProspectApiTest.php | 25 ++++++++++++--- .../Unit/Support/PhoneNormalizerTest.php | 13 ++++++++ docs/observer/STATUS.md | 6 ++-- 5 files changed, 72 insertions(+), 16 deletions(-) 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-сессий.