6e771636ef
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст, видит цену ДО отправки и журнал после. Отправки СМС в проекте не было вообще — ни у СМС-центра (только баланс и 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>
94 lines
3.6 KiB
PHP
94 lines
3.6 KiB
PHP
<?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 откатывал бы
|
||
* только соединение по умолчанию — номера 79990000001–79990000006 утекали бы из
|
||
* прогона в прогон и второй запуск падал бы на 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]);
|
||
});
|