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>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Sms\SmsOutgoing;
|
||||
use App\Services\Sms\SmsProvider;
|
||||
use App\Services\Sms\SmsRouter;
|
||||
use App\Services\Sms\SmsSendResult;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/** Провайдер-двойник: обслуживает заданных операторов по заданной цене. */
|
||||
function fakeProviderFor(string $key, array $operators, int $price): SmsProvider
|
||||
{
|
||||
return new class($key, $operators, $price) implements SmsProvider
|
||||
{
|
||||
public function __construct(
|
||||
private string $k,
|
||||
private array $ops,
|
||||
private int $price,
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->k;
|
||||
}
|
||||
|
||||
public function servesOperators(): array
|
||||
{
|
||||
return $this->ops;
|
||||
}
|
||||
|
||||
public function priceKopecks(string $operator): int
|
||||
{
|
||||
return $this->price;
|
||||
}
|
||||
|
||||
public function send(SmsOutgoing $m): SmsSendResult
|
||||
{
|
||||
return new SmsSendResult('x', 1, $this->price, CarbonImmutable::now());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('билайновский номер уходит через Билайн, а не через дорогого универсала', function () {
|
||||
$router = new SmsRouter([
|
||||
fakeProviderFor('mts', ['*'], 990),
|
||||
fakeProviderFor('beeline', ['ВымпелКом'], 475),
|
||||
]);
|
||||
|
||||
expect($router->pick('ВымпелКом')?->key())->toBe('beeline');
|
||||
});
|
||||
|
||||
it('чужой оператор уходит через универсала', function () {
|
||||
$router = new SmsRouter([
|
||||
fakeProviderFor('mts', ['*'], 990),
|
||||
fakeProviderFor('beeline', ['ВымпелКом'], 475),
|
||||
]);
|
||||
|
||||
expect($router->pick('МегаФон')?->key())->toBe('mts');
|
||||
});
|
||||
|
||||
it('при неизвестном операторе берёт универсала', function () {
|
||||
$router = new SmsRouter([fakeProviderFor('mts', ['*'], 990)]);
|
||||
|
||||
expect($router->pick('')?->key())->toBe('mts');
|
||||
});
|
||||
|
||||
it('без подходящего провайдера возвращает null — номер будет пропущен, деньги целы', function () {
|
||||
$router = new SmsRouter([fakeProviderFor('beeline', ['ВымпелКом'], 475)]);
|
||||
|
||||
expect($router->pick('МегаФон'))->toBeNull();
|
||||
});
|
||||
|
||||
it('из двух подходящих выбирает дешёвого', function () {
|
||||
$router = new SmsRouter([
|
||||
fakeProviderFor('dear', ['МТС'], 900),
|
||||
fakeProviderFor('cheap', ['МТС'], 503),
|
||||
]);
|
||||
|
||||
expect($router->pick('МТС')?->key())->toBe('cheap');
|
||||
});
|
||||
Reference in New Issue
Block a user