diff --git a/app/.env.example b/app/.env.example index 3e82ef22..c8c30cee 100644 --- a/app/.env.example +++ b/app/.env.example @@ -118,6 +118,9 @@ SMS_SANDBOX=true SMS_FAILOVER_TO_UNIVERSAL=false SMS_SMSC_ENABLED=false SMS_SMSC_PRICE_KOP=0 +# Имя отправителя универсального канала — им подписываемся, когда своего имени +# у клиента для сети получателя нет. Пусто = уйдёт без имени. +SMSC_NAMING= SMS_MTS_ENABLED=false SMS_MTS_TOKEN= SMS_MTS_PRICE_KOP=0 diff --git a/app/app/Http/Controllers/Concerns/ResolvesClientSmsSenderName.php b/app/app/Http/Controllers/Concerns/ResolvesClientSmsSenderName.php index 78a91ac4..c8201b9b 100644 --- a/app/app/Http/Controllers/Concerns/ResolvesClientSmsSenderName.php +++ b/app/app/Http/Controllers/Concerns/ResolvesClientSmsSenderName.php @@ -19,6 +19,15 @@ trait ResolvesClientSmsSenderName /** * Эффективное имя отправителя: активное согласованное имя тенанта, иначе * согласованное имя МТС из конфига или liderra.ru по умолчанию. + * + * 🪤 Это ЭКРАННОЕ имя и запись намерения (`campaign.sender_name`), а НЕ то, + * чем подпишется каждая сеть. Чем подписаться в сети конкретного оператора, + * решает `App\Services\ClientSms\ClientSmsSenderResolver` в момент отправки: + * имя согласовывает оператор, и у каждого оно своё. + * + * Подставлять отсюда имя прямо в канал — та самая ошибка, что дала живой + * отказ Теле2 `invalid_source_address` 04.08.2026: умолчание здесь взято + * от МТС и для других сетей неверно. */ protected function effectiveSenderName(int $tenantId): string { diff --git a/app/app/Jobs/SendAutoSmsForDealJob.php b/app/app/Jobs/SendAutoSmsForDealJob.php index 87920890..679b8ff8 100644 --- a/app/app/Jobs/SendAutoSmsForDealJob.php +++ b/app/app/Jobs/SendAutoSmsForDealJob.php @@ -11,6 +11,7 @@ use App\Models\Deal; use App\Services\Advertising\AdWalletService; use App\Services\ClientSms\ClientSmsPricing; use App\Services\ClientSms\ClientSmsRecipientSelector; +use App\Services\ClientSms\ClientSmsSenderResolver; use App\Services\ClientSms\SmsQuietHours; use App\Services\DaData\DaDataBudgetGuard; use App\Services\DaData\DaDataPhoneClient; @@ -77,13 +78,18 @@ class SendAutoSmsForDealJob implements ShouldQueue SmsQuietHours $quiet, DaDataPhoneClient $daData, DaDataBudgetGuard $budget, + ?ClientSmsSenderResolver $senders = null, ): void { + // Необязательный довод: вызовы с шестью аргументами не ломаются, а в бою + // контейнер отдаёт тот же самый объект. + $senders ??= app(ClientSmsSenderResolver::class); + // Авто-СМС — best-effort: ЛЮБОЙ сбой логируем и выходим, НИКОГДА не роняем. // Под sync-очередью (и в тестах) джоб может исполниться прямо в запросе // создания лида — исключение отсюда сломало бы приём лида. Идемпотентность // (deal_id + external_key) делает потерянный прогон безопасным для повтора. try { - $this->run($selector, $pricing, $wallet, $quiet, $daData, $budget); + $this->run($selector, $pricing, $wallet, $quiet, $daData, $budget, $senders); } catch (\Throwable $e) { Log::warning('client_sms.auto_job_failed', [ 'deal_id' => $this->dealId, @@ -100,6 +106,7 @@ class SendAutoSmsForDealJob implements ShouldQueue SmsQuietHours $quiet, DaDataPhoneClient $daData, DaDataBudgetGuard $budget, + ClientSmsSenderResolver $senders, ): void { $sandbox = (bool) config('services.sms.sandbox'); @@ -224,10 +231,13 @@ class SendAutoSmsForDealJob implements ShouldQueue $provider = $row['provider']; try { + // Имя отправителя — ПО ОПЕРАТОРУ получателя (см. ClientSmsSenderResolver): + // согласовывает его оператор, и у каждого оно своё. Пустая строка означает + // «своего имени для этой сети нет» — канал подпишется собственным. $result = $provider->send(new SmsOutgoing( phone: $row['phone'], body: $rule->body, - senderName: $rule->sender_name, + senderName: $senders->forOperator($this->tenantId, (string) $row['operator']), operator: $row['operator'], segments: $segments, )); diff --git a/app/app/Jobs/SendClientSmsCampaignJob.php b/app/app/Jobs/SendClientSmsCampaignJob.php index 37ab2b38..c4af73a5 100644 --- a/app/app/Jobs/SendClientSmsCampaignJob.php +++ b/app/app/Jobs/SendClientSmsCampaignJob.php @@ -10,6 +10,7 @@ use App\Models\ClientSmsCampaign; use App\Models\ClientSmsMessage; use App\Services\Advertising\AdWalletService; use App\Services\ClientSms\ClientSmsPricing; +use App\Services\ClientSms\ClientSmsSenderResolver; use App\Services\ClientSms\ClientSmsSnapshotReader; use App\Services\Sms\SmsOutgoing; use App\Services\Sms\SmsSendException; @@ -67,7 +68,12 @@ class SendClientSmsCampaignJob implements ShouldQueue ClientSmsSnapshotReader $snapshot, ClientSmsPricing $pricing, AdWalletService $wallet, + ?ClientSmsSenderResolver $senders = null, ): void { + // Необязательный довод: вызовы с тремя аргументами (тесты, старые места) + // не ломаются, а в бою контейнер отдаёт тот же самый объект. + $senders ??= app(ClientSmsSenderResolver::class); + $sandbox = (bool) config('services.sms.sandbox'); // Фаза A — под tenant-контекстом: статус и уже отправленное. Аудиторию @@ -182,10 +188,16 @@ class SendClientSmsCampaignJob implements ShouldQueue $provider = $row['provider']; try { + // Имя отправителя — ПО ОПЕРАТОРУ получателя, а не одно на всех: + // согласовывает имя оператор, и у каждого оно своё. Пустая строка + // здесь означает «своего имени для этой сети нет» — канал подпишется + // собственным согласованным именем из настроек. Раньше сюда шло + // `campaign.sender_name`, умолчанием которого было имя МТС: Теле2 + // отвечал на него `invalid_source_address` (живой отказ 04.08.2026). $result = $provider->send(new SmsOutgoing( phone: $row['phone'], body: $campaign->body, - senderName: $campaign->sender_name, + senderName: $senders->forOperator($this->tenantId, (string) $row['operator']), operator: $row['operator'], segments: $segments, )); diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 79442d2c..55dd510d 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -151,6 +151,8 @@ class AppServiceProvider extends ServiceProvider (string) config('services.smsc.password'), $serves, $price, + // Чем подписаться, если своего имени у клиента для этой сети нет. + (string) config('services.smsc.naming', ''), ) : null, 'mts' => ($token = (string) config('services.sms.mts.token')) !== '' diff --git a/app/app/Services/ClientSms/ClientSmsSenderResolver.php b/app/app/Services/ClientSms/ClientSmsSenderResolver.php new file mode 100644 index 00000000..1b10b39a --- /dev/null +++ b/app/app/Services/ClientSms/ClientSmsSenderResolver.php @@ -0,0 +1,49 @@ +where('tenant_id', $tenantId) + ->where('status', ClientSmsSender::STATUS_ACTIVE) + ->first(); + + if ($sender === null) { + return ''; + } + + /** @var array|null $operators */ + $operators = $sender->operators; + + // Список не заполнен — запись заведена до появления разбора по + // операторам. Отнять у неё имя молча значило бы поменять поведение + // задним числом, поэтому считаем «годится всем». + if ($operators === null || $operators === []) { + return (string) $sender->name; + } + + return in_array($operator, $operators, true) ? (string) $sender->name : ''; + } +} diff --git a/app/app/Services/Sms/Providers/SmscSmsProvider.php b/app/app/Services/Sms/Providers/SmscSmsProvider.php index b55904c5..9bc7b294 100644 --- a/app/app/Services/Sms/Providers/SmscSmsProvider.php +++ b/app/app/Services/Sms/Providers/SmscSmsProvider.php @@ -28,6 +28,13 @@ final class SmscSmsProvider implements SmsProvider private readonly string $password, private readonly array $serves, private readonly array $priceKopecks, + /** + * Своё согласованное имя канала. Подставляется, когда в сообщении имя + * пустое — то есть «своего имени у клиента для сети получателя нет». + * До 05.08.2026 универсал слал имя КАК ЕСТЬ и ушёл бы с пустым + * отправителем, как только имена стали выбираться по оператору. + */ + private readonly string $naming = '', ) {} public function key(): string @@ -53,7 +60,7 @@ final class SmscSmsProvider implements SmsProvider 'psw' => $this->password, 'phones' => $message->phone, 'mes' => $message->body, - 'sender' => $message->senderName, + 'sender' => $message->senderName !== '' ? $message->senderName : $this->naming, 'charset' => 'utf-8', 'fmt' => 3, ]); diff --git a/app/config/services.php b/app/config/services.php index 42b374dc..ee52dc69 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -141,6 +141,11 @@ return [ 'red_floor_rub' => (int) env('SMSC_RED_FLOOR_RUB', 300), 'amber_floor_rub' => (int) env('SMSC_AMBER_FLOOR_RUB', 1000), 'topup_url' => env('SMSC_TOPUP_URL', 'https://smsc.ru/oplata/'), + // Своё согласованное имя отправителя универсального канала. Подставляется, + // когда своего имени у клиента для сети получателя нет: имя согласовывает + // ОПЕРАТОР, а универсал ходит во все сети сразу (см. ClientSmsSenderResolver). + // Пусто — уйдёт без имени, как было до 05.08.2026. + 'naming' => env('SMSC_NAMING', ''), ], // AITUNNEL (OpenAI-совместимый) для автоподбора: эмбеддинги (похожесть конкурента на diff --git a/app/tests/Feature/ClientSms/AutoSmsTest.php b/app/tests/Feature/ClientSms/AutoSmsTest.php index e33c019a..14b4d93f 100644 --- a/app/tests/Feature/ClientSms/AutoSmsTest.php +++ b/app/tests/Feature/ClientSms/AutoSmsTest.php @@ -7,6 +7,7 @@ use App\Models\AdWallet; use App\Models\ClientSmsAutoRule; use App\Models\ClientSmsMessage; use App\Models\ClientSmsOptout; +use App\Models\ClientSmsSender; use App\Models\Deal; use App\Models\Tenant; use App\Models\User; @@ -19,7 +20,10 @@ use App\Services\DaData\DaDataBudgetGuard; use App\Services\DaData\DaDataPhoneClient; use App\Services\Sms\OperatorNormalizer; use App\Services\Sms\Providers\MtsSmsProvider; +use App\Services\Sms\SmsOutgoing; +use App\Services\Sms\SmsProvider; use App\Services\Sms\SmsRouter; +use App\Services\Sms\SmsSendResult; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; @@ -341,3 +345,105 @@ it('3.8 — авто-СМС считается по ступени с учёто ->and($m->segments)->toBe(1) // журнал помнит объём (В-108) ->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('992.00'); }); + +/** + * Канал, запоминающий, каким именем его просили подписать. Имя своё, не как в + * SendJobTest: помощники Pest — ГЛОБАЛЬНЫЕ, одноимённые уронили бы прогон. + */ +// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только +// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён. +function autoRecordingProvider() +{ + return new class implements SmsProvider + { + /** @var list */ + public array $seenSenderNames = []; + + public function key(): string + { + return 'mts'; + } + + public function servesOperators(): array + { + return ['mts']; + } + + public function priceKopecks(string $operator): int + { + return 0; + } + + public function send(SmsOutgoing $message): SmsSendResult + { + $this->seenSenderNames[] = $message->senderName; + + return new SmsSendResult('MSG-AUTO-SENDER-1', $message->segments, 0, CarbonImmutable::now()); + } + }; +} + +it('авто-СМС: имя согласовано у ДРУГОГО оператора — каналу отдаём пустое', function () { + config(['services.sms.sandbox' => false]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test'); + makeAutoRule((int) $tenant->id, true); + + // Своё имя согласовано только у Теле2, а лид — из сети МТС. + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'mybrand.ru', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'operators' => ['tele2'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => now(), + ]); + + $deal = makeFreshDeal((int) $tenant->id, '79990000021'); + + $provider = autoRecordingProvider(); + $selector = new ClientSmsRecipientSelector( + new SmsRouter([$provider]), + new OperatorNormalizer, + new AllowedSmsOperators(new OperatorNormalizer), + ); + + runAutoJob((int) $deal->id, (int) $tenant->id, $selector); + + // Сначала — что сравниваем не пустоту с пустотой: сообщение реально ушло. + expect($provider->seenSenderNames)->toHaveCount(1); + expect($provider->seenSenderNames[0])->toBe(''); +}); + +it('авто-СМС: имя согласовано у ЭТОГО оператора — им и подписываем', function () { + config(['services.sms.sandbox' => false]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test'); + makeAutoRule((int) $tenant->id, true); + + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'mybrand.ru', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'operators' => ['mts'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => now(), + ]); + + $deal = makeFreshDeal((int) $tenant->id, '79990000022'); + + $provider = autoRecordingProvider(); + $selector = new ClientSmsRecipientSelector( + new SmsRouter([$provider]), + new OperatorNormalizer, + new AllowedSmsOperators(new OperatorNormalizer), + ); + + runAutoJob((int) $deal->id, (int) $tenant->id, $selector); + + expect($provider->seenSenderNames)->toBe(['mybrand.ru']); +}); diff --git a/app/tests/Feature/ClientSms/SendJobTest.php b/app/tests/Feature/ClientSms/SendJobTest.php index 10d22334..ced54a9d 100644 --- a/app/tests/Feature/ClientSms/SendJobTest.php +++ b/app/tests/Feature/ClientSms/SendJobTest.php @@ -8,6 +8,7 @@ use App\Models\ClientSmsCampaign; use App\Models\ClientSmsContact; use App\Models\ClientSmsMessage; use App\Models\ClientSmsOptout; +use App\Models\ClientSmsSender; use App\Models\Tenant; use App\Services\Advertising\AdWalletService; use App\Services\ClientSms\AllowedSmsOperators; @@ -405,3 +406,107 @@ it('В-125 — снимок читается только под пометко expect(array_filter($reads, fn ($underContext) => $underContext === false))->toBe([]); }); + +/** + * Имя отправителя выбирается ПО ОПЕРАТОРУ получателя, а не одно на всех. + * + * До 05.08.2026 джоб слал `campaign.sender_name` каждому каналу, а умолчанием + * этого поля служило `services.sms.mts.naming` — имя, согласованное с МТС. + * Теле2 отвечал на него `invalid_source_address` (живой отказ 04.08.2026). + */ +// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только +// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён. +function recordingSenderProvider(string $key, array $serves) +{ + return new class($key, $serves) implements SmsProvider + { + /** @var list */ + public array $seenSenderNames = []; + + public function __construct(private string $k, private array $serves) {} + + public function key(): string + { + return $this->k; + } + + public function servesOperators(): array + { + return $this->serves; + } + + public function priceKopecks(string $operator): int + { + return 0; + } + + public function send(SmsOutgoing $message): SmsSendResult + { + $this->seenSenderNames[] = $message->senderName; + + return new SmsSendResult('MSG-SENDER-1', $message->segments, 0, CarbonImmutable::now()); + } + }; +} + +it('имя клиента согласовано у ДРУГОГО оператора — каналу отдаём пустое, чтобы он подписался своим', function () { + config(['services.sms.sandbox' => false]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test'); + + // Своё имя согласовано только у Теле2, а шлём в сеть МТС. + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'mybrand.ru', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'operators' => ['tele2'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => now(), + ]); + + ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]); + + $provider = recordingSenderProvider('mts', ['mts']); + bindSendJobRoute($provider); + + $campaign = makeSendJobCampaign($tenant->id, 1); + makeSendJobSnapshot($campaign); + + runSendJob($campaign->id, $tenant->id); + + // Сначала убеждаемся, что сравниваем не пустоту с пустотой: номер реально ушёл. + expect($provider->seenSenderNames)->toHaveCount(1); + // И имя МТС-кампании клиенту не подставили — канал подпишется своим. + expect($provider->seenSenderNames[0])->toBe(''); +}); + +it('имя клиента согласовано у ЭТОГО оператора — им и подписываем', function () { + config(['services.sms.sandbox' => false]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test'); + + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'mybrand.ru', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_ACTIVE, + 'operators' => ['mts'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => now(), + ]); + + ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]); + + $provider = recordingSenderProvider('mts', ['mts']); + bindSendJobRoute($provider); + + $campaign = makeSendJobCampaign($tenant->id, 1); + makeSendJobSnapshot($campaign); + + runSendJob($campaign->id, $tenant->id); + + expect($provider->seenSenderNames)->toBe(['mybrand.ru']); +}); diff --git a/app/tests/Feature/ClientSms/SenderPerOperatorTest.php b/app/tests/Feature/ClientSms/SenderPerOperatorTest.php new file mode 100644 index 00000000..4bbd7a98 --- /dev/null +++ b/app/tests/Feature/ClientSms/SenderPerOperatorTest.php @@ -0,0 +1,86 @@ +naming`). + */ +uses(DatabaseTransactions::class); + +function makeSenderFor(?array $operators, string $status = ClientSmsSender::STATUS_ACTIVE, string $name = 'mybrand.ru'): Tenant +{ + $tenant = Tenant::factory()->create(); + + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => $name, + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => $status, + 'operators' => $operators, + 'monthly_fee_rub' => '0.00', + 'consent_at' => now(), + ]); + + return $tenant; +} + +it('своего имени нет вовсе — отдаёт пустую строку, чтобы канал подписался своим', function () { + $tenant = Tenant::factory()->create(); + + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe(''); +}); + +it('имя согласовано у этого оператора — отдаёт его', function () { + $tenant = makeSenderFor(['mts', 'tele2']); + + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe('mybrand.ru'); +}); + +it('имя согласовано у ДРУГОГО оператора — своим именем не подписывает', function () { + $tenant = makeSenderFor(['mts']); + + // Ровно тот случай, что дал живой отказ Теле2 04.08.2026. + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe(''); +}); + +it('список операторов не заполнен — считаем, что имя годится всем', function () { + // Старые записи заведены до появления разбора по операторам: отнять у них + // имя молча — значит поменять поведение задним числом. + $tenant = makeSenderFor(null); + + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'megafon'))->toBe('mybrand.ru'); +}); + +it('имя ещё на согласовании — не подписывает им', function () { + $tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_PENDING); + + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe(''); +}); + +it('имя приостановлено за долг — не подписывает им', function () { + $tenant = makeSenderFor(['tele2'], ClientSmsSender::STATUS_SUSPENDED); + + expect(app(ClientSmsSenderResolver::class)->forOperator($tenant->id, 'tele2'))->toBe(''); +}); + +it('чужое имя соседа не подставляет', function () { + makeSenderFor(['tele2'], ClientSmsSender::STATUS_ACTIVE, 'sosed.ru'); + $mine = Tenant::factory()->create(); + + expect(app(ClientSmsSenderResolver::class)->forOperator($mine->id, 'tele2'))->toBe(''); +}); diff --git a/app/tests/Unit/Sms/SmscSmsProviderTest.php b/app/tests/Unit/Sms/SmscSmsProviderTest.php index 90b3360f..e61c96ab 100644 --- a/app/tests/Unit/Sms/SmscSmsProviderTest.php +++ b/app/tests/Unit/Sms/SmscSmsProviderTest.php @@ -28,6 +28,43 @@ it('обслуживает всех как универсал', function () { ->and(smsc_provider()->key())->toBe('smsc'); }); +/** + * С 05.08.2026 имя отправителя выбирается ПО ОПЕРАТОРУ получателя, и пустая + * строка означает «своего имени у клиента для этой сети нет — подпишись своим». + * Универсал же передавал имя КАК ЕСТЬ и ушёл бы с пустым отправителем. + */ +it('пустое имя заменяет своим согласованным', function () { + Http::fake(['smsc.ru/*' => Http::response(['id' => 777, 'cnt' => 1], 200)]); + + $provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru'); + + $provider->send(new SmsOutgoing( + phone: '79990000001', + body: 'Здравствуйте!', + senderName: '', + operator: 'МегаФон', + segments: 1, + )); + + Http::assertSent(fn ($request) => $request['sender'] === 'liderra.ru'); +}); + +it('заданное имя клиента своим не подменяет', function () { + Http::fake(['smsc.ru/*' => Http::response(['id' => 778, 'cnt' => 1], 200)]); + + $provider = new SmscSmsProvider('login', 'secret', ['*'], ['*' => 300], 'liderra.ru'); + + $provider->send(new SmsOutgoing( + phone: '79990000001', + body: 'Здравствуйте!', + senderName: 'mybrand.ru', + operator: 'МегаФон', + segments: 1, + )); + + Http::assertSent(fn ($request) => $request['sender'] === 'mybrand.ru'); +}); + it('успешная отправка возвращает id сообщения и цену по сегментам', function () { Http::fake(['smsc.ru/*' => Http::response(['id' => 555, 'cnt' => 1], 200)]);