Files
portal/app/tests/Feature/Sales/SmsRecipientSelectorTest.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

94 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesSmsMessage;
use App\Models\SalesSmsOptout;
use App\Services\Sms\Providers\FakeSmsProvider;
use App\Services\Sms\SmsRecipientSelector;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Отбор получателей СМС (Task 6). Все отсевы — ДО списания денег.
*
* SharesSupplierPdo обязателен: SalesAdAudiencePhone/SalesSmsOptout прибиты к
* pgsql_supplier (отдельный PDO), без шаринга DatabaseTransactions откатывал бы
* только соединение по умолчанию — номера 7999000000179990000006 утекали бы из
* прогона в прогон и второй запуск падал бы на unique violation.
* Образец — app/tests/Feature/Sales/AdAudienceScreenTest.php:27.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** Отборщик с заглушкой-универсалом. Функцией, а не $this-> — PHPStan не понимает
* привязку $this в Pest и ругается на каждое обращение. */
function sms_selector(): SmsRecipientSelector
{
return new SmsRecipientSelector(new SmsRouter([new FakeSmsProvider]));
}
function sms_phone(string $phone, array $overrides = []): SalesAdAudiencePhone
{
return SalesAdAudiencePhone::create(array_merge([
'phone' => $phone,
'expires_at' => now()->addDays(30),
'state' => 'active',
'operator' => 'МТС',
'phone_type' => 'mobile',
], $overrides));
}
it('годный номер попадает в отправку', function () {
sms_phone('79990000001');
$plan = sms_selector()->build(['79990000001']);
expect($plan->sendable)->toHaveCount(1)
->and($plan->skipped)->toBeEmpty();
});
it('номер из стоп-листа не отправляется никогда', function () {
sms_phone('79990000002');
SalesSmsOptout::create(['phone' => '79990000002', 'reason' => 'manual', 'created_at' => now()]);
$plan = sms_selector()->build(['79990000002']);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000002' => SalesSmsMessage::SKIP_OPTOUT]);
});
it('городской номер не отправляется — деньги сгорели бы впустую', function () {
sms_phone('79990000003', ['phone_type' => 'landline']);
$plan = sms_selector()->build(['79990000003']);
expect($plan->skipped)->toBe(['79990000003' => SalesSmsMessage::SKIP_NOT_MOBILE]);
});
it('погашенный номер не отправляется', function () {
sms_phone('79990000004', ['removed_at' => now()]);
$plan = sms_selector()->build(['79990000004']);
expect($plan->skipped)->toBe(['79990000004' => SalesSmsMessage::SKIP_INACTIVE]);
});
it('дубль внутри одного запуска считается один раз', function () {
sms_phone('79990000005');
$plan = sms_selector()->build(['79990000005', '79990000005']);
expect($plan->sendable)->toHaveCount(1);
});
it('без подходящего провайдера номер помечается без маршрута', function () {
sms_phone('79990000006');
$selector = new SmsRecipientSelector(new SmsRouter([]));
$plan = $selector->build(['79990000006']);
expect($plan->skipped)->toBe(['79990000006' => SalesSmsMessage::SKIP_NO_ROUTE]);
});