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

83 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\BalanceTransaction;
use App\Models\SalesSmsCampaign;
use App\Models\Tenant;
use App\Services\Sms\SmsChargeService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('пока клиента нет — с баланса не списывает, но кампанию не ломает', function () {
$campaign = SalesSmsCampaign::create([
'title' => 'Своя', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => null,
]);
(new SmsChargeService)->charge($campaign, '150.00');
expect($campaign->refresh()->balance_transaction_id)->toBeNull();
});
it('списывает с баланса клиента и пишет операцию', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$campaign = SalesSmsCampaign::create([
'title' => 'Клиентская', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id,
]);
(new SmsChargeService)->charge($campaign, '150.00');
expect((string) $tenant->refresh()->balance_rub)->toBe('850.00')
->and($campaign->refresh()->balance_transaction_id)->not->toBeNull();
$tx = BalanceTransaction::find($campaign->balance_transaction_id);
expect($tx->type)->toBe(BalanceTransaction::TYPE_SMS_CHARGE)
->and($tx->amount_rub)->toBe('-150.00');
});
it('повторный запуск не списывает второй раз', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$campaign = SalesSmsCampaign::create([
'title' => 'Повтор', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id,
]);
$service = new SmsChargeService;
$service->charge($campaign, '150.00');
$service->charge($campaign->refresh(), '150.00');
expect((string) $tenant->refresh()->balance_rub)->toBe('850.00');
});
it('не списывает второй раз, даже если пометка на кампании потерялась', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$campaign = SalesSmsCampaign::create([
'title' => 'Обрыв', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id,
]);
(new SmsChargeService)->charge($campaign, '150.00');
// Имитируем обрыв: деньги списаны, а пометка на кампании не доехала.
$campaign->refresh()->update(['balance_transaction_id' => null]);
(new SmsChargeService)->charge($campaign->refresh(), '150.00');
// Списание должно остаться ОДНО.
expect((string) $tenant->refresh()->balance_rub)->toBe('850.00');
});
it('при нехватке денег не списывает ничего', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '10.00']);
$campaign = SalesSmsCampaign::create([
'title' => 'Бедная', 'body' => 'текст', 'created_by' => 1, 'tenant_id' => $tenant->id,
]);
expect(fn () => (new SmsChargeService)->charge($campaign, '150.00'))
->toThrow(InsufficientBalanceException::class);
expect((string) $tenant->refresh()->balance_rub)->toBe('10.00')
->and($campaign->refresh()->balance_transaction_id)->toBeNull();
});