Files
portal/app/tests/Feature/Sales/AdAudienceIntakeOperatorTest.php
T
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).

Что сделано:
- разъём провайдера SmsProvider: новый оператор подключается одним файлом
- заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования
  имени отправителя у операторов (это недели), иначе разработку не закончить
- маршрутизация по оператору: билайновский номер уходит через Билайн за
  4,75 ₽, прочие через МТС — без ручного выбора канала
- стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием
- отбор получателей с шестью причинами пропуска, все ДО траты денег
- списание скопировано с AutopodborChargeService; пока клиента нет
  (tenant_id пуст) с баланса не берём — платим оператору напрямую
- оператор номера доезжает из «Поиска клиентов» в прогрев (был известен
  и оплачен ДаДате, но терялся при передаче)

Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах
СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой,
а не переделкой модуля.

Найдено и закрыто при исполнении:
- замок от двойного списания стоял не на том соединении: кампания на
  pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался
  сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку
  «оплачено» при неушедших деньгах. Источник правды перенесён в
  balance_transactions под замок по тенанту. Доказано тестом: старый код
  списывал 700 вместо 850
- приём в портал требовал phones строкой по regex — словарь с оператором
  получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что
  звали сервис МИМО контроллера. Проверка теперь принимает оба формата,
  тест идёт через HTTP
- телефоны директоров в contacts остаются строками (договор
  SalesProspectController), словари — только в верхнем phones

Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник
createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter
после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние
прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату
роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает
22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут.

Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226
зелёный, статанализ чист. Все защиты проверены вырезанием.

План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md
Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:35:43 +03:00

55 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\SalesAdAudiencePhone;
use App\Services\Sales\AdAudienceIntake;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 11: оператор номера доезжает из «Поиска клиентов» в прогрев.
*
* Оператор и тип уже определены и ОПЛАЧЕНЫ ДаДате на стороне поисковика
* (phonecheck.py → provider/kind). Терялись они ровно на передаче: pick_ad_phones()
* отдавал голые строки. Без оператора маршрутизация СМС не может выбрать дешёвый канал.
*
* Приём обязан понимать ОБА формата разом: новый (словарь с оператором) и старый
* (голая строка). Python-служба переезжает не мгновенно — ломать приём нельзя.
*
* SharesSupplierPdo обязателен: SalesAdAudiencePhone прибит к pgsql_supplier
* (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только соединение
* по умолчанию — записи текли бы из теста в тест и второй прогон падал бы на
* SQLSTATE[23505]. ПДн: только фиктивные номера 7999000000X.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('сохраняет оператора, пришедшего из поиска клиентов', function () {
(new AdAudienceIntake)->ingest([[
'firm_name' => 'ООО Ромашка',
'firm_inn' => '7700000001',
'phones' => [
['phone' => '79990000001', 'operator' => 'ВымпелКом', 'phone_type' => 'mobile'],
],
]]);
$row = SalesAdAudiencePhone::where('phone', '79990000001')->first();
expect($row)->not->toBeNull()
->and($row->operator)->toBe('ВымпелКом')
->and($row->phone_type)->toBe('mobile');
});
it('принимает и старый формат — голый список номеров', function () {
(new AdAudienceIntake)->ingest([[
'firm_name' => 'ООО Старая',
'firm_inn' => '7700000002',
'phones' => ['79990000002'],
]]);
$row = SalesAdAudiencePhone::where('phone', '79990000002')->first();
expect($row)->not->toBeNull()
->and($row->operator)->toBeNull();
});