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>
82 lines
2.6 KiB
PHP
82 lines
2.6 KiB
PHP
<?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');
|
|
});
|