2026-07-21 06:35:43 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\SendSmsCampaignJob;
|
2026-07-23 13:55:16 +03:00
|
|
|
|
use App\Models\SalesAdAudienceFirm;
|
2026-07-21 06:35:43 +03:00
|
|
|
|
use App\Models\SalesAdAudiencePhone;
|
2026-07-23 13:55:16 +03:00
|
|
|
|
use App\Models\SalesAdAudienceWarmingEpisode;
|
2026-07-21 06:35:43 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-23 13:55:16 +03:00
|
|
|
|
// ── B3: отправка СМС пишет эпизод прогрева (летопись витрины) ────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// СМС — разовая акция: один эпизод на ФИРМУ в кампании (у фирмы может быть
|
|
|
|
|
|
// несколько номеров в одной рассылке — это одна «стрельба», не N). Повтор в
|
|
|
|
|
|
// новой кампании = новый эпизод («СМС ×N» = число волн).
|
|
|
|
|
|
|
|
|
|
|
|
function sms_sender_campaign(): SalesSmsCampaign
|
|
|
|
|
|
{
|
|
|
|
|
|
// name — VARCHAR(11) (буквенный sender ID), уникум не влезает; две кампании
|
|
|
|
|
|
// одного теста («СМС ×2») переиспользуют один отправитель через firstOrCreate
|
|
|
|
|
|
// (unique tenant_id+name+provider_key).
|
|
|
|
|
|
$sender = SalesSmsSender::firstOrCreate(
|
|
|
|
|
|
['name' => 'liderra.ru', 'provider_key' => 'fake'],
|
|
|
|
|
|
['status' => 'active'],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return SalesSmsCampaign::create([
|
|
|
|
|
|
'title' => 'Прогрев', 'body' => 'Здравствуйте!', 'created_by' => 1,
|
|
|
|
|
|
'sender_id' => $sender->id, 'status' => SalesSmsCampaign::STATUS_QUEUED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sms_firm_phone(SalesAdAudienceFirm $firm, string $phone): void
|
|
|
|
|
|
{
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => $phone, 'firm_id' => $firm->id, 'expires_at' => now()->addDays(30),
|
|
|
|
|
|
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('успешная отправка СМС пишет мгновенный эпизод прогрева фирме номера', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
|
|
|
|
|
|
sms_firm_phone($firm, '79990000101');
|
|
|
|
|
|
|
|
|
|
|
|
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000101']))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
$ep = SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->sole();
|
|
|
|
|
|
expect($ep->started_at->timestamp)->toBe($ep->ended_at->timestamp);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('фирма с двумя номерами в ОДНОЙ кампании даёт один эпизод СМС', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
|
|
|
|
|
|
sms_firm_phone($firm, '79990000111');
|
|
|
|
|
|
sms_firm_phone($firm, '79990000112');
|
|
|
|
|
|
|
|
|
|
|
|
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000111', '79990000112']))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->count())->toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('две кампании той же фирме дают два эпизода (СМС ×2)', function () {
|
|
|
|
|
|
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тест', 'warmup_started_at' => now()]);
|
|
|
|
|
|
sms_firm_phone($firm, '79990000121');
|
|
|
|
|
|
|
|
|
|
|
|
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000121']))->handle();
|
|
|
|
|
|
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000121']))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(SalesAdAudienceWarmingEpisode::where('firm_id', $firm->id)->where('channel', 'sms')->count())->toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('номер без фирмы (сирота) эпизод не пишет', function () {
|
|
|
|
|
|
// Номер без firm_id (сирота до v8.77) отправляется, но эпизод фирме писать некому.
|
|
|
|
|
|
SalesAdAudiencePhone::create([
|
|
|
|
|
|
'phone' => '79990000131', 'expires_at' => now()->addDays(30),
|
|
|
|
|
|
'state' => 'active', 'operator' => 'МТС', 'phone_type' => 'mobile',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
(new SendSmsCampaignJob(sms_sender_campaign()->id, ['79990000131']))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(SalesAdAudienceWarmingEpisode::where('channel', 'sms')->count())->toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-21 06:35:43 +03:00
|
|
|
|
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');
|
|
|
|
|
|
});
|