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>
119 lines
5.1 KiB
PHP
119 lines
5.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\SendSmsCampaignJob;
|
||
use App\Models\SalesAdAudiencePhone;
|
||
use App\Models\SalesSmsCampaign;
|
||
use App\Models\SalesSmsMessage;
|
||
use App\Models\SalesSmsOptout;
|
||
use App\Models\SalesSmsSender;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
/**
|
||
* Джоб отправки рассылки (Task 8).
|
||
*
|
||
* SharesSupplierPdo обязателен: модели sales_sms_* и sales_ad_audience_phones
|
||
* прибиты к pgsql_supplier (отдельный PDO), без шаринга DatabaseTransactions
|
||
* откатывал бы только соединение по умолчанию — номера 79990000001–79990000007
|
||
* утекали бы из прогона в прогон и второй запуск падал бы на unique violation.
|
||
* Образец — app/tests/Feature/Sales/AdAudienceScreenTest.php:27.
|
||
*/
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
/**
|
||
* Кампания с активным именем отправителя и годными номерами прогрева.
|
||
* Функцией с префиксом sms_, а не $this-> — PHPStan не понимает привязку $this
|
||
* в Pest (тот же приём, что sms_phone() в SmsRecipientSelectorTest).
|
||
*
|
||
* @param array<int, string> $phones
|
||
*/
|
||
function sms_campaign_with_phones(array $phones): SalesSmsCampaign
|
||
{
|
||
$sender = SalesSmsSender::create([
|
||
'name' => 'liderra.ru', 'provider_key' => 'fake', 'status' => 'active',
|
||
]);
|
||
|
||
foreach ($phones as $p) {
|
||
SalesAdAudiencePhone::create([
|
||
'phone' => $p, 'expires_at' => now()->addDays(30),
|
||
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
|
||
]);
|
||
}
|
||
|
||
return SalesSmsCampaign::create([
|
||
'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1,
|
||
'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED,
|
||
]);
|
||
}
|
||
|
||
it('отправляет каждому годному номеру и закрывает кампанию', function () {
|
||
$campaign = sms_campaign_with_phones(['79990000001', '79990000002']);
|
||
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000001', '79990000002']))->handle();
|
||
|
||
$campaign->refresh();
|
||
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_DONE)
|
||
->and($campaign->sent_count)->toBe(2)
|
||
->and($campaign->messages)->toHaveCount(2);
|
||
});
|
||
|
||
it('пропущенные номера попадают в журнал с причиной, но не в отправку', function () {
|
||
$campaign = sms_campaign_with_phones(['79990000003', '79990000004']);
|
||
SalesSmsOptout::create(['phone' => '79990000004', 'reason' => 'manual', 'created_at' => now()]);
|
||
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000003', '79990000004']))->handle();
|
||
|
||
$campaign->refresh();
|
||
expect($campaign->sent_count)->toBe(1)
|
||
->and($campaign->skipped_count)->toBe(1);
|
||
|
||
$skipped = SalesSmsMessage::where('campaign_id', $campaign->id)
|
||
->where('phone', '79990000004')->first();
|
||
|
||
expect($skipped->status)->toBe(SalesSmsMessage::SKIP_OPTOUT)
|
||
->and($skipped->cost_kopecks)->toBe(0);
|
||
});
|
||
|
||
it('не стартует без согласованного имени отправителя', function () {
|
||
$campaign = sms_campaign_with_phones(['79990000005']);
|
||
$campaign->sender->update(['status' => 'pending']);
|
||
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000005']))->handle();
|
||
|
||
$campaign->refresh();
|
||
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED)
|
||
->and($campaign->last_error)->toContain('имя отправителя')
|
||
->and($campaign->messages)->toHaveCount(0);
|
||
});
|
||
|
||
it('повторный запуск не создаёт вторых сообщений', function () {
|
||
$campaign = sms_campaign_with_phones(['79990000006']);
|
||
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle();
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000006']))->handle();
|
||
|
||
expect(SalesSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(1);
|
||
});
|
||
|
||
it('при нехватке денег у клиента не уходит ни одной СМС', function () {
|
||
$tenant = Tenant::factory()->create(['balance_rub' => '1.00']);
|
||
$campaign = sms_campaign_with_phones(['79990000007']);
|
||
$campaign->update(['tenant_id' => $tenant->id, 'estimated_cost_kopecks' => 50000]);
|
||
|
||
(new SendSmsCampaignJob($campaign->id, ['79990000007']))->handle();
|
||
|
||
$campaign->refresh();
|
||
expect($campaign->status)->toBe(SalesSmsCampaign::STATUS_FAILED)
|
||
->and($campaign->sent_count)->toBe(0)
|
||
->and($campaign->last_error)->toContain('Не хватило денег');
|
||
|
||
$row = SalesSmsMessage::where('campaign_id', $campaign->id)->first();
|
||
expect($row->status)->toBe(SalesSmsMessage::SKIP_NO_FUNDS);
|
||
|
||
// Деньги не тронуты: списания не было, потому что его не хватало.
|
||
expect((string) $tenant->refresh()->balance_rub)->toBe('1.00');
|
||
});
|